The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by
removing the max tokens filter.
├── .github
├── FUNDING.yml
└── workflows
│ └── main.yml
├── .gitignore
├── LICENSE
├── README.md
├── arduino
└── deej-5-sliders-vanilla
│ └── deej-5-sliders-vanilla.ino
├── assets
├── build-3d-annotated.png
├── build-3d.png
├── build-shoebox.jpg
├── community-builds
│ ├── abbythegryphon.jpg
│ ├── aithorn.jpg
│ ├── bao.jpg
│ ├── bgrier.jpg
│ ├── bupher.jpg
│ ├── colemorris.jpg
│ ├── cptnobvious.jpg
│ ├── daggr.jpg
│ ├── dimitar.jpg
│ ├── druciferredbeard.jpg
│ ├── extra
│ │ ├── dimitar-schematic.png
│ │ └── dimitar-schematic.sch
│ ├── fantasticfeature84.jpg
│ ├── functionalism.jpg
│ ├── ginjah.jpg
│ ├── humidlettuce.jpg
│ ├── imaginefabricationlab.jpg
│ ├── jeremytodd.jpg
│ ├── kawaru86.jpg
│ ├── marcioasf.jpg
│ ├── max.jpg
│ ├── mozza.jpg
│ ├── mozzav2.jpg
│ ├── nightfox939.jpg
│ ├── ocyrus99.jpg
│ ├── olijoe.jpg
│ ├── omnisai.jpg
│ ├── optagon.jpg
│ ├── probird.jpg
│ ├── scotte.jpg
│ ├── snackya.jpg
│ ├── snaglebagle.jpg
│ ├── snow.jpg
│ ├── stalkymuffin.jpg
│ ├── thesqueakywheel.jpg
│ ├── tpo.jpg
│ ├── wshaf.jpg
│ └── yamoef.jpg
├── logo-512.png
├── logo.svg
└── schematic.png
├── community.md
├── config.yaml
├── docs
├── CONTRIBUTING.md
└── faq
│ ├── assets
│ ├── deej-debug
│ │ ├── explorer.png
│ │ ├── process-sessions.png
│ │ ├── quit-deej.png
│ │ └── run-prompt.png
│ ├── serial-mon.png
│ ├── sliders-and-knobs.png
│ ├── under-construction.png
│ └── wiring-methods.png
│ └── faq.md
├── go.mod
├── go.sum
└── pkg
└── deej
├── assets
├── deej.manifest
├── logo.ico
└── menu-items
│ ├── edit-config.ico
│ └── refresh-sessions.ico
├── cmd
├── main.go
└── rsrc_windows.syso
├── config.go
├── deej.go
├── icon
└── icon.go
├── logger.go
├── notify.go
├── panic.go
├── scripts
├── README.md
├── linux
│ ├── build-all.sh
│ ├── build-dev.sh
│ └── build-release.sh
├── misc
│ ├── default-config.yaml
│ └── release-notes.txt
└── windows
│ ├── build-all.bat
│ ├── build-dev.bat
│ ├── build-release.bat
│ ├── make-icon.bat
│ ├── make-rsrc.bat
│ └── prepare-release.bat
├── serial.go
├── session.go
├── session_finder.go
├── session_finder_linux.go
├── session_finder_windows.go
├── session_linux.go
├── session_map.go
├── session_windows.go
├── slider_map.go
├── tray.go
└── util
├── util.go
├── util_linux.go
└── util_windows.go
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | ko_fi: omriharel
2 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches: [master]
6 | pull_request:
7 | branches: [master]
8 |
9 | jobs:
10 | build:
11 | name: Build
12 | runs-on: ${{ matrix.os }}
13 | strategy:
14 | matrix:
15 | os: [windows-latest, ubuntu-latest]
16 | mode: [release, dev]
17 | go: ["1.14"]
18 |
19 | steps:
20 | - name: Setup Go
21 | uses: actions/setup-go@v2
22 | with:
23 | go-version: ${{ matrix.go }}
24 |
25 | - name: Install prerequisites (Linux)
26 | if: runner.os == 'Linux'
27 | run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libappindicator3-dev libwebkit2gtk-4.0-dev
28 |
29 | - name: Checkout
30 | uses: actions/checkout@v2
31 |
32 | - name: Build deej (Windows)
33 | if: runner.os == 'Windows'
34 | run: pkg/deej/scripts/windows/build-${{ matrix.mode }}.bat
35 | shell: cmd
36 |
37 | - name: Build deej (Linux)
38 | if: runner.os == 'Linux'
39 | run: pkg/deej/scripts/linux/build-${{ matrix.mode }}.sh
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | venv/
2 | *.pyc
3 | *.db
4 | .DS_Store
5 | *.log
6 | .vscode
7 | *.lnk
8 | *.exe
9 | deej-dev
10 | deej-release
11 | releases/
12 | *.bin
13 | logs/
14 | preferences.yaml
15 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Omri Harel
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # deej
2 |
3 | deej is an **open-source hardware volume mixer** for Windows and Linux PCs. It lets you use real-life sliders (like a DJ!) to **seamlessly control the volumes of different apps** (such as your music player, the game you're playing and your voice chat session) without having to stop what you're doing.
4 |
5 | **Join the [deej Discord server](https://discord.gg/nf88NJu) if you need help or have any questions!**
6 |
7 | [](https://discord.gg/nf88NJu)
8 |
9 | > **_New:_** [work-in-progress deej FAQ](./docs/faq/faq.md)!
10 |
11 | deej consists of a [lightweight desktop client](#features) written in Go, and an Arduino-based hardware setup that's simple and cheap to build. [**Check out some versions built by members of our community!**](./community.md)
12 |
13 | **[Download the latest release](https://github.com/omriharel/deej/releases/latest) | [Video demonstration](https://youtu.be/VoByJ4USMr8) | [Build video by Tech Always](https://youtu.be/x2yXbFiiAeI)**
14 |
15 | 
16 |
17 | > _**Psst!** [No 3D printer? No problem!](./assets/build-shoebox.jpg)_ You can build deej on some cardboard, a shoebox or even a breadboard :)
18 |
19 | ## Table of contents
20 |
21 | - [Features](#features)
22 | - [How it works](#how-it-works)
23 | - [Hardware](#hardware)
24 | - [Schematic](#schematic)
25 | - [Software](#software)
26 | - [Slider mapping (configuration)](#slider-mapping-configuration)
27 | - [Build your own!](#build-your-own)
28 | - [FAQ](#faq)
29 | - [Build video](#build-video)
30 | - [Bill of Materials](#bill-of-materials)
31 | - [Thingiverse collection](#thingiverse-collection)
32 | - [Build procedure](#build-procedure)
33 | - [How to run](#how-to-run)
34 | - [Requirements](#requirements)
35 | - [Download and installation](#download-and-installation)
36 | - [Building from source](#building-from-source)
37 | - [Community](#community)
38 | - [License](#license)
39 |
40 | ## Features
41 |
42 | deej is written in Go and [distributed](https://github.com/omriharel/deej/releases/latest) as a portable (no installer needed) executable.
43 |
44 | - Bind apps to different sliders
45 | - Bind multiple apps per slider (i.e. one slider for all your games)
46 | - Bind the master channel
47 | - Bind "system sounds" (on Windows)
48 | - Bind specific audio devices by name (on Windows)
49 | - Bind currently active app (on Windows)
50 | - Bind all other unassigned apps
51 | - Control your microphone's input level
52 | - Lightweight desktop client, consuming around 10MB of memory
53 | - Runs from your system tray
54 | - Helpful notifications to let you know if something isn't working
55 |
56 | > **Looking for the older Python version?** It's no longer maintained, but you can always find it in the [`legacy-python` branch](https://github.com/omriharel/deej/tree/legacy-python).
57 |
58 | ## How it works
59 |
60 | ### Hardware
61 |
62 | - The sliders are connected to 5 (or as many as you like) analog pins on an Arduino Nano/Uno board. They're powered from the board's 5V output (see schematic)
63 | - The board connects via a USB cable to the PC
64 |
65 | #### Schematic
66 |
67 | 
68 |
69 | ### Software
70 |
71 | - The code running on the Arduino board is a [C program](./arduino/deej-5-sliders-vanilla/deej-5-sliders-vanilla.ino) constantly writing current slider values over its serial interface
72 | - The PC runs a lightweight [Go client](./pkg/deej/cmd/main.go) in the background. This client reads the serial stream and adjusts app volumes according to the given configuration file
73 |
74 | ## Slider mapping (configuration)
75 |
76 | deej uses a simple YAML-formatted configuration file named [`config.yaml`](./config.yaml), placed alongside the deej executable.
77 |
78 | The config file determines which applications (and devices) are mapped to which sliders, and which parameters to use for the connection to the Arduino board, as well as other user preferences.
79 |
80 | **This file auto-reloads when its contents are changed, so you can change application mappings on-the-fly without restarting deej.**
81 |
82 | It looks like this:
83 |
84 | ```yaml
85 | slider_mapping:
86 | 0: master
87 | 1: chrome.exe
88 | 2: spotify.exe
89 | 3:
90 | - pathofexile_x64.exe
91 | - rocketleague.exe
92 | 4: discord.exe
93 |
94 | # set this to true if you want the controls inverted (i.e. top is 0%, bottom is 100%)
95 | invert_sliders: false
96 |
97 | # settings for connecting to the arduino board
98 | com_port: COM4
99 | baud_rate: 9600
100 |
101 | # adjust the amount of signal noise reduction depending on your hardware quality
102 | # supported values are "low" (excellent hardware), "default" (regular hardware) or "high" (bad, noisy hardware)
103 | noise_reduction: default
104 | ```
105 |
106 | - `master` is a special option to control the master volume of the system _(uses the default playback device)_
107 | - `mic` is a special option to control your microphone's input level _(uses the default recording device)_
108 | - `deej.unmapped` is a special option to control all apps that aren't bound to any slider ("everything else")
109 | - On Windows, `deej.current` is a special option to control whichever app is currently in focus
110 | - On Windows, you can specify a device's full name, i.e. `Speakers (Realtek High Definition Audio)`, to bind that device's level to a slider. This doesn't conflict with the default `master` and `mic` options, and works for both input and output devices.
111 | - Be sure to use the full device name, as seen in the menu that comes up when left-clicking the speaker icon in the tray menu
112 | - `system` is a special option on Windows to control the "System sounds" volume in the Windows mixer
113 | - All names are case-**in**sensitive, meaning both `chrome.exe` and `CHROME.exe` will work
114 | - You can create groups of process names (using a list) to either:
115 | - control more than one app with a single slider
116 | - choose whichever process in the group that's currently running (i.e. to have one slider control any game you're playing)
117 |
118 | ## Build your own!
119 |
120 | Building deej is very simple. You only need a few relatively cheap parts - it's an excellent starter project (and my first Arduino project, personally). Remember that if you need any help or have a question that's not answered here, you can always [join the deej Discord server](https://discord.gg/nf88NJu).
121 |
122 | Build deej for yourself, or as an awesome gift for your gaming buddies!
123 |
124 | ### FAQ
125 |
126 | I've started a highly focused effort of writing a proper FAQ page for deej, covering many basic and advanced topics.
127 |
128 | It is still _very much a work-in-progress_, but I'm happy to [share it in its current state](./docs/faq/faq.md) in hopes that it at least covers some questions you might have.
129 |
130 | FAQ feedback in our [community Discord](https://discord.gg/nf88NJu) is strongly encouraged :)
131 |
132 | ### Build video
133 |
134 | In case you prefer watching to reading, Charles from the [**Tech Always**](https://www.youtube.com/c/TechAlways) YouTube channel has made [**a fantastic video**](https://youtu.be/x2yXbFiiAeI) that covers the basics of building deej for yourself, including parts, costs, assembly and software. I highly recommend checking it out!
135 |
136 | ### Bill of Materials
137 |
138 | - An Arduino Nano, Pro Micro or Uno board
139 | - I officially recommend using a Nano or a Pro Micro for their smaller form-factor, friendlier USB connectors and more analog pins. Plus they're cheaper
140 | - You can also use any other development board that has a Serial over USB interface
141 | - A few slider potentiometers, up to your number of free analog pins (the cheaper ones cost around 1-2 USD each, and come with a standard 10K Ohm variable resistor. These _should_ work just fine for this project)
142 | - **Important:** make sure to get **linear** sliders, not logarithmic ones! Check the product description
143 | - You can also use circular knobs if you like
144 | - Some wires
145 | - Any kind of box to hold everything together. **You don't need a 3D printer for this project!** It works fantastically with just a piece of cardboard or a shoebox. That being said, if you do have one, read on...
146 |
147 | ### Thingiverse collection
148 |
149 | With many different 3D-printed designs being added to our [community showcase](./community.md), it felt right to gather all of them in a Thingiverse collection for you to browse. If you have access to a 3D printer, feel free to use one of the designs in your build.
150 |
151 | **[Visit our community-created design collection on Thingiverse!](https://thingiverse.com/omriharel/collections/deej)**
152 |
153 | > You can also [submit your own](https://discord.gg/nf88NJu) design to be added to the collection. Regardless, if you do upload your design to Thingiverse, _please add a `deej` tag to it so that others can find it more easily_.
154 |
155 |
156 | ### Build procedure
157 |
158 | - Connect everything according to the [schematic](#schematic)
159 | - Test with a multimeter to be sure your sliders are hooked up correctly
160 | - Flash the Arduino chip with the sketch in [`arduino\deej-5-sliders-vanilla`](./arduino/deej-5-sliders-vanilla/deej-5-sliders-vanilla.ino)
161 | - _Important:_ If you have more or less than 5 sliders, you must edit the sketch to match what you have
162 | - After flashing, check the serial monitor. You should see a constant stream of values separated by a pipe (`|`) character, e.g. `0|240|1023|0|483`
163 | - When you move a slider, its corresponding value should move between 0 and 1023
164 | - Congratulations, you're now ready to run the deej executable!
165 |
166 | ## How to run
167 |
168 | ### Requirements
169 |
170 | #### Windows
171 |
172 | - Windows. That's it
173 |
174 | #### Linux
175 |
176 | - Install `libgtk-3-dev`, `libappindicator3-dev` and `libwebkit2gtk-4.0-dev` for system tray support. Pre-built Linux binaries aren't currently released, so you'll need to [build from source](#building-from-source). If there's demand for pre-built binaries, please [let me know](https://discord.gg/nf88NJu)!
177 |
178 | ### Download and installation
179 |
180 | - Head over to the [releases page](https://github.com/omriharel/deej/releases) and download the [latest version](https://github.com/omriharel/deej/releases/latest)'s executable and configuration file (`deej.exe` and `config.yaml`)
181 | - Place them in the same directory anywhere on your machine
182 | - (Optional, on Windows) Create a shortcut to `deej.exe` and copy it to `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup` to have deej run on boot
183 |
184 | ### Building from source
185 |
186 | If you'd rather not download a compiled executable, or want to extend deej or modify it to your needs, feel free to clone the repository and build it yourself. All you need is a Go 1.14 (or above) environment on your machine. If you go this route, make sure to check out the [developer scripts](./pkg/deej/scripts).
187 |
188 | Like other Go packages, you can also use the `go get` tool: `go get -u github.com/omriharel/deej`. Please note that the package code now resides in the `pkg/deej` directory, and needs to be imported from there if used inside another project.
189 |
190 | If you need any help with this, please [join our Discord server](https://discord.gg/nf88NJu).
191 |
192 | ## Community
193 |
194 | [](https://discord.gg/nf88NJu)
195 |
196 | deej is a relatively new project, but a vibrant and awesome community is rapidly growing around it. Come hang out with us in the [deej Discord server](https://discord.gg/nf88NJu), or check out a whole bunch of cool and creative builds made by our members in the [community showcase](./community.md).
197 |
198 | The server is also a great place to ask questions, suggest features or report bugs (but of course, feel free to use GitHub if you prefer).
199 |
200 | ### Donations
201 |
202 | If you love deej and want to show your support for this project, you can do so using the link below. Please don't feel obligated to donate - building the project and telling your friends about it goes a very long way! Thank you very much.
203 |
204 | [](https://ko-fi.com/omriharel)
205 |
206 | ### Contributing
207 |
208 | Please see [`docs/CONTRIBUTING.md`](./docs/CONTRIBUTING.md).
209 |
210 | ## License
211 |
212 | deej is released under the [MIT license](./LICENSE).
213 |
--------------------------------------------------------------------------------
/arduino/deej-5-sliders-vanilla/deej-5-sliders-vanilla.ino:
--------------------------------------------------------------------------------
1 | const int NUM_SLIDERS = 5;
2 | const int analogInputs[NUM_SLIDERS] = {A0, A1, A2, A3, A4};
3 |
4 | int analogSliderValues[NUM_SLIDERS];
5 |
6 | void setup() {
7 | for (int i = 0; i < NUM_SLIDERS; i++) {
8 | pinMode(analogInputs[i], INPUT);
9 | }
10 |
11 | Serial.begin(9600);
12 | }
13 |
14 | void loop() {
15 | updateSliderValues();
16 | sendSliderValues(); // Actually send data (all the time)
17 | // printSliderValues(); // For debug
18 | delay(10);
19 | }
20 |
21 | void updateSliderValues() {
22 | for (int i = 0; i < NUM_SLIDERS; i++) {
23 | analogSliderValues[i] = analogRead(analogInputs[i]);
24 | }
25 | }
26 |
27 | void sendSliderValues() {
28 | String builtString = String("");
29 |
30 | for (int i = 0; i < NUM_SLIDERS; i++) {
31 | builtString += String((int)analogSliderValues[i]);
32 |
33 | if (i < NUM_SLIDERS - 1) {
34 | builtString += String("|");
35 | }
36 | }
37 |
38 | Serial.println(builtString);
39 | }
40 |
41 | void printSliderValues() {
42 | for (int i = 0; i < NUM_SLIDERS; i++) {
43 | String printedString = String("Slider #") + String(i + 1) + String(": ") + String(analogSliderValues[i]) + String(" mV");
44 | Serial.write(printedString.c_str());
45 |
46 | if (i < NUM_SLIDERS - 1) {
47 | Serial.write(" | ");
48 | } else {
49 | Serial.write("\n");
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/assets/build-3d-annotated.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/build-3d-annotated.png
--------------------------------------------------------------------------------
/assets/build-3d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/build-3d.png
--------------------------------------------------------------------------------
/assets/build-shoebox.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/build-shoebox.jpg
--------------------------------------------------------------------------------
/assets/community-builds/abbythegryphon.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/abbythegryphon.jpg
--------------------------------------------------------------------------------
/assets/community-builds/aithorn.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/aithorn.jpg
--------------------------------------------------------------------------------
/assets/community-builds/bao.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/bao.jpg
--------------------------------------------------------------------------------
/assets/community-builds/bgrier.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/bgrier.jpg
--------------------------------------------------------------------------------
/assets/community-builds/bupher.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/bupher.jpg
--------------------------------------------------------------------------------
/assets/community-builds/colemorris.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/colemorris.jpg
--------------------------------------------------------------------------------
/assets/community-builds/cptnobvious.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/cptnobvious.jpg
--------------------------------------------------------------------------------
/assets/community-builds/daggr.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/daggr.jpg
--------------------------------------------------------------------------------
/assets/community-builds/dimitar.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/dimitar.jpg
--------------------------------------------------------------------------------
/assets/community-builds/druciferredbeard.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/druciferredbeard.jpg
--------------------------------------------------------------------------------
/assets/community-builds/extra/dimitar-schematic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/extra/dimitar-schematic.png
--------------------------------------------------------------------------------
/assets/community-builds/fantasticfeature84.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/fantasticfeature84.jpg
--------------------------------------------------------------------------------
/assets/community-builds/functionalism.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/functionalism.jpg
--------------------------------------------------------------------------------
/assets/community-builds/ginjah.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/ginjah.jpg
--------------------------------------------------------------------------------
/assets/community-builds/humidlettuce.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/humidlettuce.jpg
--------------------------------------------------------------------------------
/assets/community-builds/imaginefabricationlab.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/imaginefabricationlab.jpg
--------------------------------------------------------------------------------
/assets/community-builds/jeremytodd.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/jeremytodd.jpg
--------------------------------------------------------------------------------
/assets/community-builds/kawaru86.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/kawaru86.jpg
--------------------------------------------------------------------------------
/assets/community-builds/marcioasf.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/marcioasf.jpg
--------------------------------------------------------------------------------
/assets/community-builds/max.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/max.jpg
--------------------------------------------------------------------------------
/assets/community-builds/mozza.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/mozza.jpg
--------------------------------------------------------------------------------
/assets/community-builds/mozzav2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/mozzav2.jpg
--------------------------------------------------------------------------------
/assets/community-builds/nightfox939.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/nightfox939.jpg
--------------------------------------------------------------------------------
/assets/community-builds/ocyrus99.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/ocyrus99.jpg
--------------------------------------------------------------------------------
/assets/community-builds/olijoe.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/olijoe.jpg
--------------------------------------------------------------------------------
/assets/community-builds/omnisai.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/omnisai.jpg
--------------------------------------------------------------------------------
/assets/community-builds/optagon.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/optagon.jpg
--------------------------------------------------------------------------------
/assets/community-builds/probird.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/probird.jpg
--------------------------------------------------------------------------------
/assets/community-builds/scotte.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/scotte.jpg
--------------------------------------------------------------------------------
/assets/community-builds/snackya.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/snackya.jpg
--------------------------------------------------------------------------------
/assets/community-builds/snaglebagle.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/snaglebagle.jpg
--------------------------------------------------------------------------------
/assets/community-builds/snow.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/snow.jpg
--------------------------------------------------------------------------------
/assets/community-builds/stalkymuffin.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/stalkymuffin.jpg
--------------------------------------------------------------------------------
/assets/community-builds/thesqueakywheel.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/thesqueakywheel.jpg
--------------------------------------------------------------------------------
/assets/community-builds/tpo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/tpo.jpg
--------------------------------------------------------------------------------
/assets/community-builds/wshaf.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/wshaf.jpg
--------------------------------------------------------------------------------
/assets/community-builds/yamoef.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/community-builds/yamoef.jpg
--------------------------------------------------------------------------------
/assets/logo-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/logo-512.png
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
1 | <?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 512 512" id="Layer_1" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient gradientUnits="userSpaceOnUse" id="SVGID_1_" x1="256" x2="256" y1="512" y2="-9.094947e-013"><stop offset="0" style="stop-color:#8E54E9"/><stop offset="1" style="stop-color:#4776E6"/></linearGradient><circle cx="256" cy="256" fill="url(#SVGID_1_)" r="256"/><g><path d="M175.4,282.1V142.7c0-2.8-2.3-5.1-5.1-5.1s-5.1,2.3-5.1,5.1v139.4c-19.1,2.5-34,18.8-34,38.5 c0,19.8,14.9,36,34,38.5v10.2c0,2.8,2.3,5.1,5.1,5.1s5.1-2.3,5.1-5.1v-10.2c19.1-2.5,34-18.8,34-38.5 C209.4,300.8,194.5,284.6,175.4,282.1z M170.3,349.5C170.3,349.5,170.3,349.5,170.3,349.5C170.3,349.5,170.3,349.5,170.3,349.5 c-15.9,0-28.9-13-28.9-28.9c0-15.9,13-28.9,28.9-28.9s28.9,12.9,28.9,28.9C199.2,336.5,186.2,349.5,170.3,349.5z" fill="#FFFFFF"/><path d="M380.7,282.5c0-19.8-14.9-36-34-38.5V142.7c0-2.8-2.3-5.1-5.1-5.1c-2.8,0-5.1,2.3-5.1,5.1V244 c-19.1,2.5-34,18.7-34,38.5c0,19.8,14.9,36,34,38.5v48.3c0,2.8,2.3,5.1,5.1,5.1c2.8,0,5.1-2.3,5.1-5.1v-48.3 C365.9,318.5,380.7,302.3,380.7,282.5z M341.7,311.4c-15.9,0-28.9-12.9-28.9-28.9c0-15.9,13-28.9,28.9-28.9 c15.9,0,28.9,13,28.9,28.9C370.5,298.4,357.6,311.4,341.7,311.4z" fill="#FFFFFF"/><path d="M261.7,152.9v-10.2c0-2.8-2.3-5.1-5.1-5.1c-2.8,0-5.1,2.3-5.1,5.1v10.2c-19.1,2.5-34,18.8-34,38.5 s14.9,36,34,38.5v139.4c0,2.8,2.3,5.1,5.1,5.1c2.8,0,5.1-2.3,5.1-5.1V230c19.1-2.5,34-18.8,34-38.5S280.9,155.4,261.7,152.9z M256.7,220.3C256.7,220.3,256.6,220.3,256.7,220.3C256.6,220.3,256.6,220.3,256.7,220.3c-15.9,0-28.9-13-28.9-28.9 c0-15.9,13-28.9,28.9-28.9c15.9,0,28.9,13,28.9,28.9C285.5,207.3,272.6,220.3,256.7,220.3z" fill="#FFFFFF"/></g></svg>
--------------------------------------------------------------------------------
/assets/schematic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/assets/schematic.png
--------------------------------------------------------------------------------
/community.md:
--------------------------------------------------------------------------------
1 | # Community showcase
2 |
3 | This is a showcase of `deej` versions built by people around the world. Many of those who featured their builds here regularly hang around in [our Discord server](https://discord.gg/nf88NJu), so you can ask them any questions you might have.
4 |
5 | We've also gathered many of the community-created 3D designs featured here in a [Thingiverse collection](https://thingiverse.com/omriharel/collections/deej) for your viewing pleasure. Feel free to use them for your own builds if you have access to a 3D printer.
6 |
7 | If you built yourself one of these things, I'd love to [add yours](https://discord.gg/nf88NJu)!
8 |
9 | ## [/u/Aithorn](https://reddit.com/user/Aithorn)
10 |
11 | **Links**: [Imgur album](https://imgur.com/a/Y1rKJmc) | [Reddit](https://redd.it/fc2l3x) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4196719) | [Featured on Arduino Blog!](https://blog.arduino.cc/2020/03/04/control-the-volume-of-programs-running-on-your-windows-pc-like-a-dj/) | [Alternate knobs](https://i.imgur.com/WjaA58d.jpg)
12 |
13 | This build features a very neat 3D-printed enclosure to fit the sliders, and uses an Arduino Nano board.
14 |
15 | 
16 |
17 | ## [Bupher](https://github.com/Bupher)
18 |
19 | **Links**: [Imgur album](https://imgur.com/a/5uHYhxW) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4641048) | [GitHub fork](https://github.com/Bupher/deej) | [Video 1](https://youtu.be/O2UJv-sJEjA) | [Video 2](https://youtu.be/psEt-rckr8U)
20 |
21 | This updated build uses circular knobs instead of sliders, and adds a 7-segment display showing the volume of the last adjusted knob. The five buttons are used to trigger various macros that the author configured separately. Most recently this build was augmented to include built-in support for Corsair's iCUE RGB control. This allows using Corsair's desktop software to control the lighting on this deej design similarly to an RGB keyboard. The required Arduino-side code modifications are available in Bupher's fork (link above). Very cool!
22 |
23 | 
24 |
25 | ## Dimitar
26 |
27 | **Links**: Schematic ([.png](./assets/community-builds/extra/dimitar-schematic.png) | [.sch](./assets/community-builds/extra/dimitar-schematic.sch))
28 |
29 | This build, based on the above 3D-printed enclosure by [/u/Aithorn](#uaithorn), adds onto it with additional holes drilled for fading LEDs and mute toggles. Their wiring is detailed in the schematic that Dimitar has kindly provided (link above).. The sleek dark appearance, coupled with the differently colored knobs and LEDs, makes for a very professional look.
30 |
31 | 
32 |
33 | ## [/u/jeremytodd1](https://reddit.com/user/jeremytodd1)
34 |
35 | **Links**: [Imgur album](https://imgur.com/a/ys1RLwr)
36 |
37 | This build is based on the above 3D-printed enclosure by [/u/Aithorn](#uaithorn) and also uses their 3D-printed knobs for its sliders.
38 |
39 | 
40 |
41 | ## CptnObvious
42 |
43 | **Links**: [Imgur album](https://imgur.com/a/pnptoo7)
44 |
45 | This build uses a flat 3D-printed enclosure, but the real kicker is its magnetically-snapping application logo covers, made with a midway-filament-swapping technique. Looking snappy!
46 |
47 | 
48 |
49 | ## nightfox939
50 |
51 | **Links**: [Imgur album](https://imgur.com/a/rrLbTHI) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4460296)
52 |
53 | This build takes a different spin on 3D-printed enclosures by positioning the sliders at an angle, such that the box can be placed right behind the keyboard (as shown [here](https://i.imgur.com/FuvaaTO.jpg)). It also features seven sliders, which is fantastic for power users.
54 |
55 | 
56 |
57 | ## [Snackya](https://github.com/Snackya)
58 |
59 | **Links**: [Imgur album](https://imgur.com/a/ZL6UBuR) | [GitHub repository with PCB files and instructions](https://github.com/Snackya/Snackboard-mix)
60 |
61 | This one's a very special build, as it's designed to look great with absolutely no 3D printing, mill or any casing at all. It's made with custom PCBs that are mounted together with bolts and nuts. The top plate's silkscreen layer adds its own visual flair. The build uses six 100 mm sliders and an Arduino Pro Micro. Snackya has kindly provided the gerber files, as well as detailed instructions, in their GitHub repository (link above).
62 |
63 | 
64 |
65 | ## [olijoe](https://github.com/olijoe)
66 |
67 | **Links**: [Imgur album](https://imgur.com/a/Wibnqi7) | [GitHub repository with PCB files and instructions](https://github.com/olijoe/Deej-board)
68 |
69 | This PCB build also takes advantage of sandwiching between two identical custom PCBs to avoid any need for 3D printing or other tools. Both sides of the PCB are used, one for mounting and wiring the components and the other for the silkscreen design (which in this case features some app logos and a cute geometric alpaca). olijoe has kindly provided the gerber files, as well as detailed insturctions, in their GitHub repository (link above).
70 |
71 | 
72 |
73 | ## [Daggr](https://www.thingiverse.com/daggr)
74 |
75 | **Links**: [Imgur album](https://imgur.com/a/YmTALay) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4555424) | [Blog post](https://blogg.spofify.se/index.php/2020/07/28/physical-volume-controller-part-2/)
76 |
77 | This design is a remixed version of the above build by [nightfox939](#nightfox939), this time using five circular knobs instead of sliders. It adds onto it with a patterned top infill in the slicer settings, as well as some sharp looking filament-swapped app icons.
78 |
79 | 
80 |
81 | ## [/u/thesqueakywheel](https://reddit.com/user/thesqueakywheel)
82 |
83 | **Links**: [Imgur album](https://imgur.com/a/vTsrSa7) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4557639)
84 |
85 | This one's a compact 3-part design made to be [mounted to the underside of a desk](https://i.imgur.com/hGB5NH2.png). Each part is flat to allow for easy 3D printing without support material. The design is made such that the mounting plate in-between the two covers is actually what's holding them together and hidden between them for a seamless packaging.
86 |
87 | 
88 |
89 | ## [Optagon](https://www.thingiverse.com/Optagon)
90 |
91 | **Links**: [Imgur album](https://imgur.com/a/8WKr8W9) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4556291)
92 |
93 | This design incorporates fading LEDs that shine through 3D-printed app icons (they look way better in reality, but hopefully the picture gives a bit of an idea). Instructions for how to wire everything up, including some pictures, are inside the links above. A bright idea, and some very stylish execution!
94 |
95 | 
96 |
97 | ## mozza
98 |
99 | **Links**: [Imgur album](https://imgur.com/a/suMAJ5Y) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4558424)
100 |
101 | This compact build was designed to be positioned side-by-side with a keyboard. Contrary to most other designs, the knobs here are laid out vertically, making ideal use of the space they occupy. Perfect for a quick adjustment without ever taking off your hand from that side of the keyboard! Oh, and there's a similar version by the same author [below](#mozza-v2) if you're interested.
102 |
103 | 
104 |
105 | ## Max
106 |
107 | **Links**: [Imgur album](https://imgur.com/a/T8OR4b3) | [Enclosure model on PrusaPrinters](https://www.prusaprinters.org/prints/37823-deej-mixer)
108 |
109 | This build uses laser-cut acrylic as for its top plate which results in an absolutely stunning shine. It's lined with LEDs to indicate mute state, controllable by one of the two rows of buttons. The second row is used as custom triggers for F13-F19 keys, which brings a lot of added utility. This one's definitely for power users!
110 |
111 | 
112 |
113 | ## [/u/functionalism](https://reddit.com/user/functionalism)
114 |
115 | **Links**: [Imgur album](https://imgur.com/a/zavi9jL) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4561669)
116 |
117 | This build uses a 3D-printed design remixed from [/u/Aithorn](#uaithorn)'s above enclosure. This one was made to be held in place by magnets, as it's situated on top of a metallic monitor stand. Magnets are also used instead of screws to hold the top cover and the enclosure's base together. In addition, app icons have been embossed into the 3D-printed slider knobs.
118 |
119 | 
120 |
121 | ## [/u/Fantastic-Feature-84](https://reddit.com/user/Fantastic-Feature-84)
122 |
123 | **Links**: [Imgur album](https://imgur.com/a/1q2MYI1) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4562009) | [Full Instructable](https://www.instructables.com/id/Hardware-Volume-Mixer-for-PC-With-RGB-LEDs)
124 |
125 | This clever retro-style design features LEDs that change their color based on each slider's volume. These LEDs shine through light diffusers mounted below icon cutouts for the different apps, providing a controlled brightness that's easy on the eyes. In addition, a satisfying push button provides global mute toggling. /u/FantasticFeature84 has kindly provided full instructions for anyone else who wishes to build the same box, available on Instructables (link above).
126 |
127 | 
128 |
129 | ## [Cole Morris](http://colemorris.me)
130 |
131 | **Links**: [Imgur album](https://imgur.com/a/qkSlVGt)
132 |
133 | This compact 3D-printed build makes clever use of vertical space by having slight dips along each slider's travel area, making the knobs seem more integrated with the rest of the enclosure. The bright colors used for its different parts compliment each other well, and band together with its form factor and rounded edges to create a cute, toy-like appearance.
134 |
135 | 
136 |
137 | ## [wshaf](https://www.twitch.tv/wshaf)
138 |
139 | **Links**: [Imgur album](https://imgur.com/a/KWONZ5A) | [Build VOD on Twitch](https://www.twitch.tv/videos/704168677)
140 |
141 | This is a pink-and-black rendition of [/u/Aithorn](#uaithorn)'s above build, built by the author for one of their friends. Its primary use will be to control different audio sources while live-streaming on Twitch, which highlights deej's usefulness as a cheap DIY alternative to GoXLR and other similar products.
142 |
143 | 
144 |
145 | ## scotte
146 |
147 | **Links**: [Imgur album](https://imgur.com/a/Wj2Fe5w) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4567647)
148 |
149 | This one is a super-compact mini design! It features a 2-part snap-fit 3D-printed case, as well as 3D-printed knobs. With a total of three sliders and a tiny footprint of about 45 by 75 millimeters, this mini deej version is perfect for those looking to balance fewer audio sources and save some precious desk space. Did we mention it's adorable?
150 |
151 | 
152 |
153 | ## [Ginjah](https://www.twitch.tv/ginjah_)
154 |
155 | **Links**: [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4569860)
156 |
157 | This slanted, 3D-printed design consists of six potentiometers - five of them are sliders, and a large knob is used as a master volume control. It features buttons with built-in LEDs to indicate mute toggle state, with one button acting specifically as a global mute (which is the equivalent of turning down all sliders to zero) - this accomplishes muting applications across several audio devices as opposed to just `master` which uses the default. Also worth mentioning that the knob and slider caps are all 3D-printed too!
158 |
159 | 
160 |
161 | ## mozza (v2)
162 |
163 | **Links**: [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4570471)
164 |
165 | This build is a follow up to the earlier version made by mozza ([showcased above](#mozza)). It features a combination of four sliders and one master volume knob. It was designed to match the same angle as the keyboard that's next to it. In addition to that, many rounds of sanding and filling have resulted in a professional surface finish that looks like an actual product.
166 |
167 | 
168 |
169 | ## Probird
170 |
171 | This simple build consists of five knobs with, wait for it - **2D**-printed app icons attached to its top surface (yes, it's a piece of paper and some glue). This is a good example for a design that users who are brand new to 3D-printing and CAD can make entirely from scratch.
172 |
173 | 
174 |
175 | ## [/u/DruciferRedBeard](https://reddit.com/user/DruciferRedBeard)
176 |
177 | **Links**: [Imgur album](https://imgur.com/a/nBGLd9b) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4568300)
178 |
179 | This wonderfully colorful build was made [twice](https://i.imgur.com/v3FQXkF.jpg) by its author, one for themselves and one for their son. It's a clever 3D-printed design featuring no visible screws on its top surface, achieved by mounting a stabilizer in-between the top and bottom covers. The knobs have also been printed out with a satisfying range of colors (meant to represent different apps). The indicator line down the middle of each slider is made using the "color change" feature in PrusaSlicer. Elegant _and_ cheerful!
180 |
181 | 
182 |
183 | ## Snaglebagle
184 |
185 | **Links**: [Imgur album](https://imgur.com/a/QN7gyjP) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4579910)
186 |
187 | This bright, red and black design has a neat retro look to it that's further complemented by its sharp corners. It has five sliders and uses the 3D-printed knobs designed by [/u/Aithorn](#uaithorn).
188 |
189 | 
190 |
191 | ## [/u/Kawaru86](https://reddit.com/user/Kawaru86)
192 |
193 | **Links**: [Imgur album](https://imgur.com/a/q2fENDF)
194 |
195 | This unique build is based entirely on a breadboard! Two, to be exact. The author took some great pictures that [show the wiring](https://i.imgur.com/hUHCKup.jpg) for the project in a very clear (and quite satisfying) way. This is a fantastic, [stylish](https://i.imgur.com/2ETrBIE.jpg) choice for people without access to a 3D printer, or those looking for a way to build deej without soldering anything.
196 |
197 | 
198 |
199 | ## [/u/HumidLettuce](https://reddit.com/user/HumidLettuce)
200 |
201 | **Links**: [Imgur album](https://imgur.com/a/AEpAJYi) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4580787) | [Full Instructable](https://www.instructables.com/id/Deej-Box-5-Sliders/)
202 |
203 | This 3D-printed design has a smooth surface finish and features no front-facing screws. It uses magnetic, swappable plates with app icons below each slider, allowing you to easily tell which slider controls which app. In addition, the steep angle of this build lets it occupy slightly less desk space than other, more traditional slider-based designs. /u/HumidLettuce has kindly provided full, step-by-step build instructions for other users wishing to use this design, available on Instructables (link above).
204 |
205 | 
206 |
207 | ## [Snow](https://reddit.com/user/NoU_14)
208 |
209 | **Links**: [Imgur album](https://imgur.com/a/3XYqhJk)
210 |
211 | This compact design uses a single rotary encoder (with a modified version of deej's Arduino code) that acts as several "virtual" knobs. Clicking the encoder toggles the control between them, with the active channel (and current volume level) being indicated by the OLED screen. It also incorporates an LED ring to act as a secondary volume level indicator, adding some visual flair to the build. The 3D-printed enclosure wraps everything up with a professional look, that (unintentionally!) somewhat resembles a classic iPod. The bottom side of the enclosure is filled with some plaster, giving it additional weight and allowing it to remain sturdy despite its small (8x4.5x2.3 cm) size.
212 |
213 | 
214 |
215 | ## YaMoef
216 |
217 | **Links**: [Imgur album](https://imgur.com/a/3zxhjxF) | [Modified code on GitHub](https://github.com/YaMoef/deej)
218 |
219 | This build presents a second, angled variation on the single rotary encoder approach, using a single component to control multiple "virtual sliders". It uses a heavily modified version of both Arduino code and the deej client (both of which the author has kindly made available in their GitHub fork, alongside detailed instructions - link above). It features an LCD display to indicate the active channel as well as the current volume level. Its 3D-printed exterior is finished off with a shiny, metallic volume knob to wrap up its sleek appearance.
220 |
221 | 
222 |
223 | ## StalkyMuffin
224 |
225 | **Links**: [Imgur album](https://imgur.com/a/zmfc3ft) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4595058)
226 |
227 | This one is a traditional-looking slider-based build with some unique properties. It was designed with multi-material capabilities in mind, most notably apparent by the laser-cut wooden app glued to the top. These provide an interesting contrast to the multi-layered faceplate, printed with two different filaments to give the sliders their own highlight. It's also the first deej build to include a [cord strain relief](https://i.imgur.com/lK6twPm.jpg) instead of a USB port, which adds to its highly professional look and feel.
228 |
229 | 
230 |
231 | ## [ocyrus99](https://www.thingiverse.com/ocyrus99)
232 |
233 | **Links**: [Imgur album](https://imgur.com/a/g5X6OSw) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4599505)
234 |
235 | This build blends together a few different features to achieve a uniquely colorful look. It is remixed from the above build by [Optagon](#optagon) but has a lower profile. The app icons have been redesigned to include a thin 3D-printed base which acts as a light diffuser for LEDs mounted beneath each icon. These RGB LEDs are then controlled by the Arduino to transition their color as their corresponding knob is rotated. Finally, the round aluminum knobs provide a nice contrast to the plastic body, while also emitting some of the glow from the colored LEDs. If you're interested, ocyrus99 has kindly made their code available in the design's Thingiverse entry (link above). Great execution!
236 |
237 | 
238 |
239 | ## bgrier
240 |
241 | **Links**: [Imgur album](https://imgur.com/a/2LFaeVV) | [Full Instructable](https://www.instructables.com/Arduino-Keyboard-Joystick-Extender-Box-and-Sound-C/)
242 |
243 | This particularly special build will appeal to those who love analog controls (so everyone, yes?). It is a hybrid of deej together with a fully functional joystick controller! The creator is using it as a keyboard extender that can control certain interface elements, or certain things in games and simulators - but really, sky's the limit. The 3D-printed body has been designed and fabricated to match the color aesthetic of the creator's keyboard, and looks like a natural extension of its frame. As a bonus, bgrier has prepared a fully detailed Instructable for any of you who want to follow along and build it (link above). Oh, and even if you don't need a joystick, the fidget value is just incredible!
244 |
245 | 
246 |
247 | ## marcioasf
248 |
249 | **Links**: [Imgur album](https://imgur.com/a/J6UNBDB)
250 |
251 | This design is based on the above build by [Optagon](#optagon). The author chose to use 3D-printed knobs, and also printed the bottom part of the case with a translucent filament which lets the LED inside add a pleasant, diffused glow to its exterior.
252 |
253 | 
254 |
255 | ## Bao
256 |
257 | **Links**: [Imgur album](https://imgur.com/a/hrw3TvZ)
258 |
259 | This incredibly creative design brings deej to life on a tiny Mentos can. Novelties aside, this build ends up with a very compact footprint and can be a fantastic idea for anyone without access to a 3D printer. Looks tough to wire up, but if you're building with a Mentos can you _clearly_ know what you're doing.
260 |
261 | 
262 |
263 | ## AbbyTheGryphon
264 |
265 | **Links**: [Imgur album](https://imgur.com/a/USUAesF) | [Enclosure model on MyMiniFactory](https://www.myminifactory.com/object/3d-print-139957)
266 |
267 | This design incorporates an amp-style appearance with a personalized touch. The five rotary potentiometers use plastic dials with a shiny metallic top, which adds a lot to that look. Additionally, tiny markings underneath each knob indicate the travel range for the pot. Crank it up to 11 with this one!
268 |
269 | 
270 |
271 | ## T-po
272 |
273 | **Links**: [Imgur album](https://imgur.com/a/OuYZDQk)
274 |
275 | This elegant build achieves its polished, professional look by using a project case to mount its top-facing components. It uses five identical rotary potentiometers, with the shiny aluminum knobs differentiating between the master knob and the other channels. It also features a mini USB to micro USB adapter (which makes up for the Nano's most glaring downside). The creator included some descriptions and part names in their Imgur album (link above).
276 |
277 | 
278 |
279 | ## [Imagine Fabrication Lab](https://www.instagram.com/imaginefabricationlab/)
280 |
281 | **Links**: [Imgur album](https://imgur.com/a/o8nZHDc) | [Enclosure model on Thingiverse](https://www.thingiverse.com/thing:4639592)
282 |
283 | This design was made with power users in mind, incorporating no less than 12 buttons to trigger custom functionality with the Arduino Pro Micro's keyboard emulation capabilities (this requires a modified deej firmware). The 3D-printed buttons are mounted with a press fit, and can be swapped out as desired. Finally, these 45mm sliders offer a good amount of precision without taking up much space, resulting in a build that's fairly compact for the amount of firepower it packs.
284 |
285 | 
286 |
287 | ## [omnisai](https://relivesight.com/)
288 |
289 | **Links**: [Imgur album](https://imgur.com/a/Z5luqwl) | [Blog entry!](https://relivesight.com/projects/encoder/) | [One more build by the author](https://relivesight.com/projects/deejx2/)
290 |
291 | Another entry in the list of power-user oriented builds, this one-of-a-kind design was made from the case of a Behringer audio interface that its owner was willing to sacrifice. They added a fresh coat of paint, replaced the original components and made it into a thing of beauty. This build features no less than 9 potentiometers and 18 buttons, taking full advantage of everything the Arduino Pro Micro has to offer. The creator wrote a highly detailed blog post about the process on their website, I recommend giving it a read (link above)!
292 |
293 | 
294 |
--------------------------------------------------------------------------------
/config.yaml:
--------------------------------------------------------------------------------
1 | # process names are case-insensitive
2 | # you can use 'master' to indicate the master channel, or a list of process names to create a group
3 | # you can use 'mic' to control your mic input level (uses the default recording device)
4 | # you can use 'deej.unmapped' to control all apps that aren't bound to any slider (this ignores master, system, mic and device-targeting sessions)
5 | # windows only - you can use 'deej.current' to control the currently active app (whether full-screen or not)
6 | # windows only - you can use a device's full name, i.e. "Speakers (Realtek High Definition Audio)", to bind it. this works for both output and input devices
7 | # windows only - you can use 'system' to control the "system sounds" volume
8 | # important: slider indexes start at 0, regardless of which analog pins you're using!
9 | slider_mapping:
10 | 0: master
11 | 1: chrome.exe
12 | 2: spotify.exe
13 | 3:
14 | - pathofexile_x64.exe
15 | - rocketleague.exe
16 | 4: discord.exe
17 |
18 | # set this to true if you want the controls inverted (i.e. top is 0%, bottom is 100%)
19 | invert_sliders: false
20 |
21 | # settings for connecting to the arduino board
22 | com_port: COM4
23 | baud_rate: 9600
24 |
25 | # adjust the amount of signal noise reduction depending on your hardware quality
26 | # supported values are "low" (excellent hardware), "default" (regular hardware) or "high" (bad, noisy hardware)
27 | noise_reduction: default
28 |
--------------------------------------------------------------------------------
/docs/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | I'm writing this document to help set the expectations of developers looking to add features they wrote to deej's main fork.
2 |
3 | I hope that even though not everyone would be happy about my approach to this subject, we'll be able to have a civil and respectful discussion around it. If you're interested in doing so, I invite you to reach out to me via our community [Discord server](https://discord.gg/nf88NJu).
4 |
5 | Thanks for reading!
6 |
7 | ## Some background, and my vision for deej
8 |
9 | Similarly to other software developers who maintain open-source projects outside of their workplace, I work on deej - for free - in my free time, motivation and mood permitting.
10 |
11 | A lot of that time is spent interacting outside of GitHub, by running our community Discord server which is an active space with over a thousand members. This includes supporting users with their initial setup of deej, answering questions about deej and commenting on user-created builds and designs.
12 |
13 | Since the project's initial debut in February 2020, I had the pleasure of talking to hundreds of users - beginners and seasoned developers alike - which has guided my decisions on subjects like licensing and documentation, as well as helped me form the following vision for deej's future:
14 |
15 | ### Project scope
16 |
17 | I have a fairly set vision for deej and what it should be (including at which point in time I'd like to add certain things). I prefer to work on these things myself - as mentioned above, when I have the time and motivation to do so.
18 |
19 | ### Project audience
20 |
21 | Many of deej's users aren't necessarily tech-savvy, and for some of them this is their first time making a combined electronics hardware + software project. This fact influences many decisions vis-a-vis keeping things as simple as possible. I care a lot about beginners being able to get started easily, even at the cost of certain more advanced features not being included in vanilla deej.
22 |
23 | ## Pull requests and alternate forks
24 |
25 | The nature of how I currently choose to maintain deej means that **I'm not likely to accept and incorporate PRs** into deej's main fork ([omriharel/deej](https://github.com/omriharel/deej)).
26 |
27 | Despite the above, **deej is still a fully open-source project**. I don't want my occasional lack of energy to stand in the way of anyone looking to make something awesome - you have my blessing to fork the project, maintain your copy of it separately, and tell the world (_including_ our community Discord server) about it!
28 |
29 | ### Getting started with development
30 |
31 | - Have a Go 1.14+ environment
32 | - Use the build scripts under `pkg/deej/scripts` for your built binaries if you want them to have the notion of versioning
33 |
34 | ## Issues
35 |
36 | I welcome all bug reports and feature requests, and try to respond to these within a reasonable amount of time.
37 |
--------------------------------------------------------------------------------
/docs/faq/assets/deej-debug/explorer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/docs/faq/assets/deej-debug/explorer.png
--------------------------------------------------------------------------------
/docs/faq/assets/deej-debug/process-sessions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/docs/faq/assets/deej-debug/process-sessions.png
--------------------------------------------------------------------------------
/docs/faq/assets/deej-debug/quit-deej.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/docs/faq/assets/deej-debug/quit-deej.png
--------------------------------------------------------------------------------
/docs/faq/assets/deej-debug/run-prompt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/docs/faq/assets/deej-debug/run-prompt.png
--------------------------------------------------------------------------------
/docs/faq/assets/serial-mon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/docs/faq/assets/serial-mon.png
--------------------------------------------------------------------------------
/docs/faq/assets/sliders-and-knobs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/docs/faq/assets/sliders-and-knobs.png
--------------------------------------------------------------------------------
/docs/faq/assets/under-construction.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/docs/faq/assets/under-construction.png
--------------------------------------------------------------------------------
/docs/faq/assets/wiring-methods.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/docs/faq/assets/wiring-methods.png
--------------------------------------------------------------------------------
/docs/faq/faq.md:
--------------------------------------------------------------------------------
1 | # deej FAQ
2 |
3 | This document tries to cover all of the most commonly asked questions in our [community Discord server](https://discord.gg/nf88NJu), as well as provide basic troubleshooting for frequently-encountered problems by new deej users.
4 |
5 | ## Important - this is a **work-in-progress**!
6 |
7 | 
8 |
9 | **Please be aware that the current version of this FAQ is still _extremely_ work-in-progress.** It is released publicly in hopes that the questions already answered can be useful to some.
10 |
11 | **All questions that have a complete answer have been marked with a "✔" in the table of contents, for your convenience.**
12 |
13 | > Please also be aware that I am currently _not accepting_ GitHub contributions in the form of fully submitted answers to the unanswered questions below. You are welcome to submit them (alongside any other feedback) on our community Discord, however!
14 |
15 | ## Table of Contents
16 |
17 | Use the following list of questions to quickly jump to what you're looking for.
18 |
19 | > Can't find what you're looking for? Try searching for terms with a hashtag, such as _**#rotaryencoders**_. These are located near each question for easier finding of alternative keywords.
20 |
21 | [**Electronics and hardware**](#electronics-and-hardware)
22 |
23 | - **Parts & components**
24 | - [Which board should I get?](#which-board-should-i-get)
25 | - [What is the difference between sliders and knobs?](#what-is-the-difference-between-sliders-and-knobs) ✔
26 | - [Does the resistance of my pots matter? Do they need to be 10K?](#does-the-resistance-of-my-pots-matter-do-they-need-to-be-10k) ✔
27 | - [What are rotary encoders and can I use them instead of pots?](#what-are-rotary-encoders-and-can-i-use-them-instead-of-pots?)
28 | - **Wiring**
29 | - [How should I **physically** wire my pots together?](#how-should-i-physically-wire-my-pots-together)
30 | - [How should I **logically/electronically** wire my pots together?](#how-should-i-logicallyelectronically-wire-my-pots-together) ✔
31 | - [How can I tell which pin is which on my sliders?](#how-can-i-tell-which-pin-is-which-on-my-sliders)
32 | - **Extra hardware features**
33 | - [How do I add mute switches/toggles for each pot?](#how-do-i-add-mute-switchestoggles-for-each-pot)
34 | - [How do I add LED indicators?](#how-do-i-add-led-indicators)
35 | - [How do I add custom macro buttons?](#how-do-i-add-custom-macro-buttons)
36 | - [How do I add a toggle between output devices?](#how-do-i-add-a-toggle-between-output-devices)
37 |
38 |
39 | [**Arduino**](#arduino)
40 |
41 | - [I haven't used Arduino before, where to start?](#i-havent-used-arduino-before-where-to-start) ✔
42 | - [How can I find my COM/serial port?](#how-can-i-find-my-comserial-port) ✔
43 | - [How can I upload the deej sketch to my board?](#how-can-i-upload-the-deej-sketch-to-my-board) ✔
44 | - [How do I know that I uploaded the deej sketch correctly?](#how-do-i-know-that-i-uploaded-the-deej-sketch-correctly) ✔
45 | - I'm unable to connect to my board in the Arduino IDE...
46 | - [...for the first time ever](#for-the-first-time-ever) ✔
47 | - [...after connecting successfully in the past](#after-connecting-successfully-in-the-past) ✔
48 | - [How do I view the serial monitor?](#how-do-i-view-the-serial-monitor) ✔
49 |
50 | [**Running deej for the first time**](#running-deej-for-the-first-time)
51 |
52 | - [What should I check before running deej.exe?](#)
53 | - [I ran deej.exe but nothing happened](#)
54 | - I ran deej.exe and got an error message...
55 | - [...about the config.yaml file](#)
56 | - [...about failed connection](#)
57 | - [I turn my pots but nothing happens](#)
58 | - [I ran deej.exe and my volume keeps jumping around](#)
59 | - [My pots only seem to go up to around half volume](#)
60 | - [All of my pots are inverted!](#all-of-my-pots-are-inverted) ✔
61 | - [One of my pots is inverted](#one-of-my-pots-is-inverted) ✔
62 |
63 | [**Editing the config file (config.yaml)**](#editing-the-config-file-configyaml)
64 |
65 | - [How do I open and edit the config.yaml file?](#how-do-i-open-and-edit-the-configyaml-file) ✔
66 | - [How can I check my config.yaml for errors?](#how-can-i-check-my-configyaml-for-errors) ✔
67 | - [How do I add an app to the config.yaml file?](#how-do-i-add-an-app-to-the-configyaml-file) ✔
68 | - [How can I find the .exe name of `insert app here`?](#how-can-i-find-the-exe-name-of-insert-app-here) ✔
69 |
70 |
71 | [**Everyday deej usage**](#everyday-deej-usage)
72 |
73 | - [Is there a way to make the volume go up/down by less than 3%?](#)
74 | - [Can I put all my games on one slider without needing to add them one-by-one?](#can-i-put-all-my-games-on-one-slider-without-needing-to-add-them-one-by-one) ✔
75 | - [The edges of my slider aren't as responsive as the middle](#)
76 | - [How can I make deej start automatically when my PC boots?](#)
77 | - [After my computer wakes up from sleep/hibernation deej doesn't work anymore](#)
78 | - [Sometimes deej randomly stops working (without sleep/hibernation)](#)
79 |
80 | [**Component housings and enclosures**](#component-housings-and-enclosures)
81 |
82 | - [I don't have a 3D printer, can I still make this project?](#)
83 | - [I have a 3D printer - where can I find a design to print?](#)
84 |
85 | [**[↑]**](#deej-faq)
86 |
87 | ## Electronics and hardware
88 |
89 | ### What is the difference between sliders and knobs?
90 |
91 | As long as you're referring to pots - nothing. The only difference is that one of them rotates while the other slides. Electrically, they work exactly the same and they're purely up to personal preference.
92 |
93 | 
94 |
95 | <sub>_Tags: #potentiometers, #pots, #sliders, #rotarypots, #knobs_</sub>
96 |
97 | [**[↑]**](#deej-faq)
98 |
99 | ### Does the resistance of my pots matter? Do they need to be 10K?
100 |
101 | In short: **no, it doesn't matter.** Any resistance will work.
102 |
103 | Longer explanation: deej uses potentiometers as voltage dividers between a 5V pin and a GND pin. Since each analog pin (the pin connected to the pot's wiper) has its own "circuit", each pot is treated as its own voltage divider and is always the only resistor across that circuit. This also means that some of your pots can have one resistance and other ones a different one. It just makes no difference in the context of this project. [Here's a great video](https://www.youtube.com/watch?v=fmSC0NoaG_I) if you're interested in learning more about voltage dividers.
104 |
105 | <sub>_Tags: #pots, #resistance, #10k, #ohm_</sub>
106 |
107 | [**[↑]**](#deej-faq)
108 |
109 | ### How should I logically/electronically wire my pots together?
110 |
111 | There are two main options to wire your pots between one another.
112 |
113 | - The first is to "daisy chain" them (left) - each pot's positive pin connects to next pot's positive pin, with the last one going to the board. The same goes for the negative pins. You'll need to cut your wires to size for this to look good.
114 | - The second way is to just bring out a separate wire from each pot, and twist together the _exposed_ ends to one wire (which then goes to the board). This is also done one for all the positive pins, and once for all the negative pins. This method is a bit easier to do if you have thin, flexible wires.
115 |
116 | > **Note:** these drawings can also be followed with sliders (slide potentiometers) - rotary pots are used here as an example.
117 |
118 | 
119 |
120 | <sub>_Tags: #wiring, #potentiometers, #pots, #sliders, #rotarypots_</sub>
121 |
122 | [**[↑]**](#deej-faq)
123 |
124 | ## Arduino
125 |
126 | ### I haven't used Arduino before, where to start?
127 |
128 | First off, know that you're in the right place. This project is excellent for a total beginner without any electronics or Arduino knowledge. That being said, you are expected to learn some basics along the way. Here's a general suggestion for how to proceed:
129 |
130 | - Watch an introductory video explaining the basics of Arduino, such as [this excellent one](https://www.youtube.com/watch?v=nL34zDTPkcs).
131 | - Determine what type of deej build you want to go for (3D-printed or not, knobs or sliders, how many of them, etc.), according to your preferences
132 | - If this is your first electronics project, I wholeheartedly recommend to keep things simple and not add additional custom functionality. You can always add these later on (or build a second, third and fourth deej!)
133 | - Get some inspiration from our [community showcase](https://showcase.deej.rocks) and [Thingiverse design collection](https://thingiverse.com/omriharel/collections/deej)
134 | - Become somewhat familiar with how deej works. A great way to do this is to read the [how it works](https://github.com/omriharel/deej#how-it-works) section in the main README, or to simply read through most of this FAQ :)
135 | - Once you feel like you have a decent grasp over how deej works and what components you need, get shopping!
136 | - If you're doing things for the first time, it's a good idea to get a spare or two (at least for things like your board and pots) in case you mess something up. It's unlikely, but who wants to wait for a part twice as long?
137 | - When your parts arrive, take some time to brush up and make sure you understand how you should wire everything together. You can always undo some of it, but taking a brief refresher at this point might save you some time
138 | - If you run into issues or have additional questions (especially if they're not covered here), join our [community Discord server](https://discord.gg/nf88NJu) and ask away!
139 |
140 | <sub>_Tags: #beginner, #noob, #newbie, #firsttime_</sub>
141 |
142 | [**[↑]**](#deej-faq)
143 |
144 | ### How can I find my COM/serial port?
145 |
146 | **Windows users:**
147 |
148 | In most cases, you would be able to see your device in the Arduino IDE, under the _Tools_ → _Port_ menu.
149 |
150 | > _Note:_ `COM1` is reserved for use by the operating system, and very likely isn't the port of your board. If this is the only option you're seeing, your computer isn't properly detecting your board (see [here](#for-the-first-time-ever) for possible causes).
151 |
152 | Another option for Windows users is to open the Device Manager (right-click the Start icon and choose _Device Manager_) and look under _Ports (COM & LPT)_. In most cases this will show the same list as the Arduino IDE, however.
153 |
154 | **Linux users:**
155 |
156 | Generally speaking, you'll be able to find your serial port by listing your serial devices with a command such as `ls /dev/ttyUSB*` and choosing the one that makes the most sense. You can also disconnect your device, run the command again and compare both outputs to find the one that's now missing.
157 |
158 | > Please keep in mind that deej's config.yaml file requires you to use the full path, such as `/dev/ttyUSB1`.
159 |
160 | <sub>_Tags: #comport, #serialport, #devicemanager_</sub>
161 |
162 | [**[↑]**](#deej-faq)
163 |
164 | ### How can I upload the deej sketch to my board?
165 |
166 | - Start by downloading the vanilla deej sketch from [this URL](https://raw.githubusercontent.com/omriharel/deej/master/arduino/deej-5-sliders-vanilla/deej-5-sliders-vanilla.ino), either by right-click → _Save as..._ (save the file with an `.ino` extension), or simply copying and pasting its entire contents into the Arduino IDE (replacing the current open sketch).
167 | - The vanilla sketch is meant for use with 5 potentiometers, wired to analog pins `A0` to `A4`: `A0, A1, A2, A3, A4`. If you have more or less potentiometers, or they're wired to different analog pins on your board, you will need to modify the sketch to reflect those differences. You should do this _before_ uploading it to the board. To do this:
168 | - Change the number of pots on the first line of the sketch
169 | - Change the array of pin numbers on the second line of the sketch
170 | - For example, if you only need 3 pots and you wired them to `A1`, `A3` and `A5` your code would look like this:
171 |
172 | ```cpp
173 | const int NUM_SLIDERS = 3; // instead of 5
174 | const int analogInputs[NUM_SLIDERS] = {A1, A3, A5}; // instead of A0-A4
175 | ```
176 |
177 | - Make sure that you've selected the correct COM port, board type and processor according to the board you're using, and that your board is connected to the computer.
178 | - Upload the sketch by clicking the _Upload_ button in the Arduino IDE's top menu bar. This should take between 5 and 15 seconds to complete.
179 |
180 | <sub>_Tags: #arduino, #arduinoide, #sketch, #flash, #upload, #board_</sub>
181 |
182 | [**[↑]**](#deej-faq)
183 |
184 | ### How do I know that I uploaded the deej sketch correctly?
185 |
186 | The best way to tell is to check the Serial Monitor in Arduino IDE right after your upload has completed. If it displays a steady stream of numbers, separated by the pipe (`|`) character, you've correctly uploaded the deej sketch.
187 |
188 | 
189 |
190 | There are several things you want to note here:
191 |
192 | - Each line contains a total amount of numbers equal to how many potentiometers you have defined in the sketch you uploaded.
193 | - The above screenshot, for instance, represents 6 sliders. The last line doesn't count, it's still being written!
194 | - Each number corresponds to a certain pot, from left to right. Its value is a number between 0 and 1023, where 0 means "the pot is at 0%" and 1023 means "the pot is at 100%".
195 | - In the above screenshot:
196 | - the first pot sits at about 50%
197 | - the second and fourth pots are slightly below that
198 | - the third pot is slightly above that
199 | - the fifth and sixth pots are at 100%
200 | - Try moving your pots! They should behave as explained above, with each one being adjustable between the range of 0 and 1023. If this isn't the case, you might have miswired your potentiometers.
201 | - Analog values from pots are slightly different between lines, even when the pot isn't being moved. **This is normal!** Since this is an analog signal, it is subject to some noise and interference. deej internally manages that to ensure that volume only changes when you mean to change it.
202 | - If your analog values are "jumping" by a significantly larger margin than shown here, you may have miswired your potentiometers.
203 |
204 | If your serial monitor appears to be similar to this, and behaves correctly when you move all of your pots, you're good to go! Close the serial monitor, and proceed to [download and set up](https://github.com/omriharel/deej/releases/latest) deej's desktop client and its config.yaml file.
205 |
206 | <sub>_Tags: #arduino, #arduinoide, #sketch, #flash, #upload, #board, #verify_</sub>
207 |
208 | [**[↑]**](#deej-faq)
209 |
210 | ### I'm unable to connect to my board in the Arduino IDE...
211 |
212 | #### ...for the first time ever
213 |
214 | You might be **missing drivers** that correspond to your board. This is of course going to depend on which board you got (with most drivers being automatically detected and installed by your OS), but a very common driver that cheaper Nano-like boards use is the CH340.
215 |
216 | > Please keep in mind that you should only use drivers provided or instructed by your board's manufacturer. Installing drivers from a third-party website is at your own risk.
217 |
218 | Another common issue with some older Nano-like boards is simply **choosing the wrong processor** in the Arduino IDE, thus not being able to properly communicate with the board (despite setting the correct COM port). In this case, try choosing the **Old Bootloader** option under _Tools_ → _Processor_. You might have to restart your Arduino IDE after.
219 |
220 | <sub>_Tags: #connectionissue, #connectionerror, #arduinoide, #accessdenied, #unabletoconnect, #board_</sub>
221 |
222 | [**[↑]**](#deej-faq)
223 |
224 | #### ...after connecting successfully in the past
225 |
226 | First, **check that you've set the [correct COM/serial port](#how-can-i-find-my-comserial-port)** in Arduino IDE. Sometimes (especially during initial setup) this port will change between cable/USB port disconnects and reconnects.
227 |
228 | Secondly, is anything else running on your machine that might be already connected to the serial port? **Only one thing can be connected to the same serial port at the same time!** Some common culprits include:
229 |
230 | - Cura, a popular 3D slicing software
231 | - Another instance of Arduino IDE
232 | - and of course, deej itself!
233 |
234 | <sub>_Tags: #connectionissue, #connectionerror, #arduinoide, #accessdenied, #unabletoconnect, #board_</sub>
235 |
236 | [**[↑]**](#deej-faq)
237 |
238 | ### How do I view the serial monitor?
239 |
240 | First, ensure that no other program (besides Arduino IDE) is connected to the board's serial port. _This includes closing deej, in case it's already running._ Secondly, make sure that you've correctly selected the COM port in the Arduino IDE under _Tools_ → _Port_.
241 |
242 | At that point, you should be able to go to _Tools_ → _Serial Monitor_ in the Arduino IDE (or simply use the keyboard shortcut, <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>M</kbd>). You should now see the serial output from your board!
243 |
244 | <sub>_Tags: #serialmonitor, #arduino, #output, #arduinoide_</sub>
245 |
246 | [**[↑]**](#deej-faq)
247 |
248 |
249 | ## Running deej for the first time
250 |
251 | ### All of my pots are inverted
252 |
253 | This can happen if you wire their two GND/5V ends opposite to how you'd expect them to work. If you don't want to resolder anything, you can also set the `invert_sliders` option in the config.yaml file to `true`.
254 |
255 | > Please keep in mind that if you wired hardware switches to cut power to your sliders (for a purpose like mute toggling), it would instead be treated as sending 100% volume. In this case, you'll need to resolder your pots or modify the Arduino code (and not use `invert_sliders`).
256 |
257 | <sub>_Tags: #pots, #sliders, #invert, #reverse_</sub>
258 |
259 | [**[↑]**](#deej-faq)
260 |
261 | ### One of my pots is inverted
262 |
263 | In this case, simply flip the two power pins on the single problematic pot. You can also modify the arduino code to only invert that pot's value, but you probably shouldn't.
264 |
265 | <sub>_Tags: #pots, #sliders, #invert, #reverse_</sub>
266 |
267 | [**[↑]**](#deej-faq)
268 |
269 | ## Editing the config file (config.yaml)
270 |
271 | ### How do I open and edit the config.yaml file?
272 |
273 | The `config.yaml` file is a simple text file, and can be edited with _any text editor_. The simplest one on Windows is **Notepad**, but you can use a more advanced editor like **Notepad++** or **Visual Studio Code** if you prefer.
274 |
275 | > **To open the file with Notepad:**
276 | > 1. Right-click the `config.yaml` file
277 | > 2. Choose "Open with"
278 | > 3. In the window that opens, double-click Notepad
279 |
280 | <sub>_Tags: #config, #configuration, #edit, #open, #yaml_</sub>
281 |
282 | [**[↑]**](#deej-faq)
283 |
284 | ### How can I check my config.yaml for errors?
285 |
286 | You can easily make sure that your config.yaml is correctly structured. Just open a website such as [this one](https://codebeautify.org/yaml-validator), and paste your file contents into it. If there are errors, the website will also point out where they are.
287 |
288 | <sub>_Tags: #config, #configuration, #check, #validate, #yaml, #formatting_</sub>
289 |
290 | [**[↑]**](#deej-faq)
291 |
292 | ### How do I add an app to the config.yaml file?
293 |
294 | Adding apps to the config.yaml file is simple. Start by [opening the file](#how-do-i-open-and-edit-the-configyaml-file) and pay attention to the `slider_mapping` section in it:
295 |
296 | ```yaml
297 | slider_mapping:
298 | 0: master
299 | 1: chrome.exe
300 | 2: spotify.exe
301 | 3:
302 | - pathofexile_x64.exe
303 | - rocketleague.exe
304 | 4: discord.exe
305 | ```
306 |
307 | > **Important:** the slider numbers in the config start from 0, and **shouldn't be changed** depending on which pins are used. They are **always** from 0 to (however many sliders you have - 1).
308 |
309 | To change which app (or apps) are bound to each slider, simply add their process (.exe) name under the relevant slider's number. In this above example:
310 |
311 | - Slider #1 is bound to `master` (controls the master volume)
312 | - Slider #2 is bound to Google Chrome
313 | - Slider #3 is bound to Spotify
314 | - Slider #4 is bound to **both** Path of Exile and Rocket League. You can add as many different apps as you wish to a single slider!
315 | - Slider #5 is bound to Discord
316 |
317 | Other than defining groups of apps, you can use other powerful options in your `slider_mapping`. Most notably:
318 |
319 | - `deej.current` will change the volume for the currently active window
320 | - `deej.unmapped` will group together all apps that aren't bound to a slider. This is perfect for a slider to control all your games!
321 | - `mic` can be used to control the _input_ volume of your microphone.
322 |
323 | > You can check out a complete list of configuration file options [here](https://github.com/omriharel/deej#slider-mapping-configuration).
324 |
325 | <sub>_Tags: #app, #config, #configuration, #add, #set_</sub>
326 |
327 | [**[↑]**](#deej-faq)
328 |
329 | ### How can I find the .exe name of `insert-app-here`?
330 |
331 | If you're not sure what's the .exe name for the app you wanna add to deej's config.yaml file, you have a few options for how to find it.
332 |
333 | **Option 1: Task Manager**
334 |
335 | 1. Open Task Manager (Ctrl+Shift+Esc)
336 | 2. Under the "Processes" tab, find the application you care about
337 | 3. Click the expand (>) button left of its name
338 | 4. Find the application name in the expanded view, and right-click it
339 | 5. Click "Go to details"
340 | 6. The process name should now be highlighted in the "Details" tab
341 |
342 | In some cases, the app you're chasing is playing audio from a secondary process with a different name. This can be harder to find with Task Manager. But there are more ways!
343 |
344 | **Option 2: Run deej in debug mode**
345 |
346 | Keep in mind that this is a bit more complicated. You'll need to start by grabbing the debug executable of deej (`deej-debug.exe`) from the [releases page](https://github.com/omriharel/deej/releases) and downloading it to the same location where your deej.exe and config.yaml are:
347 |
348 | 
349 |
350 | After this, click the address bar and type `cmd.exe` then hit Enter. A command prompt window will open.
351 |
352 | 
353 |
354 | Before you continue, make sure to **quit deej if it's already running** in your tray!
355 |
356 | 
357 |
358 | At this point, make sure that your desired app is playing some audio, and then type `deej-debug.exe` in the newly opened command prompt and hit Enter. Take a moment to observe the printed logs from deej - you might need to do a bit of scrolling to find what you're looking for. Here's an example screenshot illustrating what to look for:
359 |
360 | 
361 |
362 | <sub>_Tags: #app, #exe, #process, #name_</sub>
363 |
364 | [**[↑]**](#deej-faq)
365 |
366 | ## Everyday deej usage
367 |
368 | ### Can I put all my games on one slider without needing to add them one-by-one?
369 |
370 | Yes. You can use the `deej.unmapped` option to bind all apps that aren't bound elsewhere in the config.yaml file. This can be used on its own, or together with other binding options on the same slider.
371 |
372 | <sub>_Tags: #games, #unmapped, #unbound, #onebyone_</sub>
373 |
374 | [**[↑]**](#deej-faq)
375 |
376 | ## Component housings and enclosures
377 |
378 | [**[↑]**](#deej-faq)
379 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/omriharel/deej
2 |
3 | go 1.14
4 |
5 | require (
6 | github.com/fsnotify/fsnotify v1.4.9
7 | github.com/gen2brain/beeep v0.0.0-20200420150314-13046a26d502
8 | github.com/getlantern/ops v0.0.0-20200403153110-8476b16edcd6 // indirect
9 | github.com/getlantern/systray v0.0.0-20200324212034-d3ab4fd25d99
10 | github.com/go-ole/go-ole v1.2.4
11 | github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect
12 | github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4
13 | github.com/jfreymuth/pulse v0.0.0-20200608153616-84b2d752b9d4
14 | github.com/lxn/walk v0.0.0-20191128110447-55ccb3a9f5c1 // indirect
15 | github.com/lxn/win v0.0.0-20191128105842-2da648fda5b4
16 | github.com/mitchellh/go-ps v1.0.0
17 | github.com/moutend/go-wca v0.1.2-0.20190422112502-0fa027b3d89a
18 | github.com/spf13/viper v1.7.1
19 | github.com/thoas/go-funk v0.7.0
20 | go.uber.org/zap v1.15.0
21 | golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 // indirect
22 | )
23 |
--------------------------------------------------------------------------------
/pkg/deej/assets/deej.manifest:
--------------------------------------------------------------------------------
1 | <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2 | <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
3 | <assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="deej" type="win32"/>
4 | <dependency>
5 | <dependentAssembly>
6 | <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
7 | </dependentAssembly>
8 | </dependency>
9 | <application xmlns="urn:schemas-microsoft-com:asm.v3">
10 | <windowsSettings>
11 | <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
12 | <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">True</dpiAware>
13 | </windowsSettings>
14 | </application>
15 | </assembly>
16 |
--------------------------------------------------------------------------------
/pkg/deej/assets/logo.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/pkg/deej/assets/logo.ico
--------------------------------------------------------------------------------
/pkg/deej/assets/menu-items/edit-config.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/pkg/deej/assets/menu-items/edit-config.ico
--------------------------------------------------------------------------------
/pkg/deej/assets/menu-items/refresh-sessions.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/pkg/deej/assets/menu-items/refresh-sessions.ico
--------------------------------------------------------------------------------
/pkg/deej/cmd/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 |
7 | "github.com/omriharel/deej/pkg/deej"
8 | )
9 |
10 | var (
11 | gitCommit string
12 | versionTag string
13 | buildType string
14 |
15 | verbose bool
16 | )
17 |
18 | func init() {
19 | flag.BoolVar(&verbose, "verbose", false, "show verbose logs (useful for debugging serial)")
20 | flag.BoolVar(&verbose, "v", false, "shorthand for --verbose")
21 | flag.Parse()
22 | }
23 |
24 | func main() {
25 |
26 | // first we need a logger
27 | logger, err := deej.NewLogger(buildType)
28 | if err != nil {
29 | panic(fmt.Sprintf("Failed to create logger: %v", err))
30 | }
31 |
32 | named := logger.Named("main")
33 | named.Debug("Created logger")
34 |
35 | named.Infow("Version info",
36 | "gitCommit", gitCommit,
37 | "versionTag", versionTag,
38 | "buildType", buildType)
39 |
40 | // provide a fair warning if the user's running in verbose mode
41 | if verbose {
42 | named.Debug("Verbose flag provided, all log messages will be shown")
43 | }
44 |
45 | // create the deej instance
46 | d, err := deej.NewDeej(logger, verbose)
47 | if err != nil {
48 | named.Fatalw("Failed to create deej object", "error", err)
49 | }
50 |
51 | // if injected by build process, set version info to show up in the tray
52 | if buildType != "" && (versionTag != "" || gitCommit != "") {
53 | identifier := gitCommit
54 | if versionTag != "" {
55 | identifier = versionTag
56 | }
57 |
58 | versionString := fmt.Sprintf("Version %s-%s", buildType, identifier)
59 | d.SetVersion(versionString)
60 | }
61 |
62 | // onwards, to glory
63 | if err = d.Initialize(); err != nil {
64 | named.Fatalw("Failed to initialize deej", "error", err)
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/pkg/deej/cmd/rsrc_windows.syso:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omriharel/deej/9c0307b96341538ec46f18d2e9b18aaa84441175/pkg/deej/cmd/rsrc_windows.syso
--------------------------------------------------------------------------------
/pkg/deej/config.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "fmt"
5 | "path"
6 | "strings"
7 | "time"
8 |
9 | "github.com/fsnotify/fsnotify"
10 | "github.com/spf13/viper"
11 | "go.uber.org/zap"
12 |
13 | "github.com/omriharel/deej/pkg/deej/util"
14 | )
15 |
16 | // CanonicalConfig provides application-wide access to configuration fields,
17 | // as well as loading/file watching logic for deej's configuration file
18 | type CanonicalConfig struct {
19 | SliderMapping *sliderMap
20 |
21 | ConnectionInfo struct {
22 | COMPort string
23 | BaudRate int
24 | }
25 |
26 | InvertSliders bool
27 |
28 | NoiseReductionLevel string
29 |
30 | logger *zap.SugaredLogger
31 | notifier Notifier
32 | stopWatcherChannel chan bool
33 |
34 | reloadConsumers []chan bool
35 |
36 | userConfig *viper.Viper
37 | internalConfig *viper.Viper
38 | }
39 |
40 | const (
41 | userConfigFilepath = "config.yaml"
42 | internalConfigFilepath = "preferences.yaml"
43 |
44 | userConfigName = "config"
45 | internalConfigName = "preferences"
46 |
47 | userConfigPath = "."
48 |
49 | configType = "yaml"
50 |
51 | configKeySliderMapping = "slider_mapping"
52 | configKeyInvertSliders = "invert_sliders"
53 | configKeyCOMPort = "com_port"
54 | configKeyBaudRate = "baud_rate"
55 | configKeyNoiseReductionLevel = "noise_reduction"
56 |
57 | defaultCOMPort = "COM4"
58 | defaultBaudRate = 9600
59 | )
60 |
61 | // has to be defined as a non-constant because we're using path.Join
62 | var internalConfigPath = path.Join(".", logDirectory)
63 |
64 | var defaultSliderMapping = func() *sliderMap {
65 | emptyMap := newSliderMap()
66 | emptyMap.set(0, []string{masterSessionName})
67 |
68 | return emptyMap
69 | }()
70 |
71 | // NewConfig creates a config instance for the deej object and sets up viper instances for deej's config files
72 | func NewConfig(logger *zap.SugaredLogger, notifier Notifier) (*CanonicalConfig, error) {
73 | logger = logger.Named("config")
74 |
75 | cc := &CanonicalConfig{
76 | logger: logger,
77 | notifier: notifier,
78 | reloadConsumers: []chan bool{},
79 | stopWatcherChannel: make(chan bool),
80 | }
81 |
82 | // distinguish between the user-provided config (config.yaml) and the internal config (logs/preferences.yaml)
83 | userConfig := viper.New()
84 | userConfig.SetConfigName(userConfigName)
85 | userConfig.SetConfigType(configType)
86 | userConfig.AddConfigPath(userConfigPath)
87 |
88 | userConfig.SetDefault(configKeySliderMapping, map[string][]string{})
89 | userConfig.SetDefault(configKeyInvertSliders, false)
90 | userConfig.SetDefault(configKeyCOMPort, defaultCOMPort)
91 | userConfig.SetDefault(configKeyBaudRate, defaultBaudRate)
92 |
93 | internalConfig := viper.New()
94 | internalConfig.SetConfigName(internalConfigName)
95 | internalConfig.SetConfigType(configType)
96 | internalConfig.AddConfigPath(internalConfigPath)
97 |
98 | cc.userConfig = userConfig
99 | cc.internalConfig = internalConfig
100 |
101 | logger.Debug("Created config instance")
102 |
103 | return cc, nil
104 | }
105 |
106 | // Load reads deej's config files from disk and tries to parse them
107 | func (cc *CanonicalConfig) Load() error {
108 | cc.logger.Debugw("Loading config", "path", userConfigFilepath)
109 |
110 | // make sure it exists
111 | if !util.FileExists(userConfigFilepath) {
112 | cc.logger.Warnw("Config file not found", "path", userConfigFilepath)
113 | cc.notifier.Notify("Can't find configuration!",
114 | fmt.Sprintf("%s must be in the same directory as deej. Please re-launch", userConfigFilepath))
115 |
116 | return fmt.Errorf("config file doesn't exist: %s", userConfigFilepath)
117 | }
118 |
119 | // load the user config
120 | if err := cc.userConfig.ReadInConfig(); err != nil {
121 | cc.logger.Warnw("Viper failed to read user config", "error", err)
122 |
123 | // if the error is yaml-format-related, show a sensible error. otherwise, show 'em to the logs
124 | if strings.Contains(err.Error(), "yaml:") {
125 | cc.notifier.Notify("Invalid configuration!",
126 | fmt.Sprintf("Please make sure %s is in a valid YAML format.", userConfigFilepath))
127 | } else {
128 | cc.notifier.Notify("Error loading configuration!", "Please check deej's logs for more details.")
129 | }
130 |
131 | return fmt.Errorf("read user config: %w", err)
132 | }
133 |
134 | // load the internal config - this doesn't have to exist, so it can error
135 | if err := cc.internalConfig.ReadInConfig(); err != nil {
136 | cc.logger.Debugw("Viper failed to read internal config", "error", err, "reminder", "this is fine")
137 | }
138 |
139 | // canonize the configuration with viper's helpers
140 | if err := cc.populateFromVipers(); err != nil {
141 | cc.logger.Warnw("Failed to populate config fields", "error", err)
142 | return fmt.Errorf("populate config fields: %w", err)
143 | }
144 |
145 | cc.logger.Info("Loaded config successfully")
146 | cc.logger.Infow("Config values",
147 | "sliderMapping", cc.SliderMapping,
148 | "connectionInfo", cc.ConnectionInfo,
149 | "invertSliders", cc.InvertSliders)
150 |
151 | return nil
152 | }
153 |
154 | // SubscribeToChanges allows external components to receive updates when the config is reloaded
155 | func (cc *CanonicalConfig) SubscribeToChanges() chan bool {
156 | c := make(chan bool)
157 | cc.reloadConsumers = append(cc.reloadConsumers, c)
158 |
159 | return c
160 | }
161 |
162 | // WatchConfigFileChanges starts watching for configuration file changes
163 | // and attempts reloading the config when they happen
164 | func (cc *CanonicalConfig) WatchConfigFileChanges() {
165 | cc.logger.Debugw("Starting to watch user config file for changes", "path", userConfigFilepath)
166 |
167 | const (
168 | minTimeBetweenReloadAttempts = time.Millisecond * 500
169 | delayBetweenEventAndReload = time.Millisecond * 50
170 | )
171 |
172 | lastAttemptedReload := time.Now()
173 |
174 | // establish watch using viper as opposed to doing it ourselves, though our internal cooldown is still required
175 | cc.userConfig.WatchConfig()
176 | cc.userConfig.OnConfigChange(func(event fsnotify.Event) {
177 |
178 | // when we get a write event...
179 | if event.Op&fsnotify.Write == fsnotify.Write {
180 |
181 | now := time.Now()
182 |
183 | // ... check if it's not a duplicate (many editors will write to a file twice)
184 | if lastAttemptedReload.Add(minTimeBetweenReloadAttempts).Before(now) {
185 |
186 | // and attempt reload if appropriate
187 | cc.logger.Debugw("Config file modified, attempting reload", "event", event)
188 |
189 | // wait a bit to let the editor actually flush the new file contents to disk
190 | <-time.After(delayBetweenEventAndReload)
191 |
192 | if err := cc.Load(); err != nil {
193 | cc.logger.Warnw("Failed to reload config file", "error", err)
194 | } else {
195 | cc.logger.Info("Reloaded config successfully")
196 | cc.notifier.Notify("Configuration reloaded!", "Your changes have been applied.")
197 |
198 | cc.onConfigReloaded()
199 | }
200 |
201 | // don't forget to update the time
202 | lastAttemptedReload = now
203 | }
204 | }
205 | })
206 |
207 | // wait till they stop us
208 | <-cc.stopWatcherChannel
209 | cc.logger.Debug("Stopping user config file watcher")
210 | cc.userConfig.OnConfigChange(nil)
211 | }
212 |
213 | // StopWatchingConfigFile signals our filesystem watcher to stop
214 | func (cc *CanonicalConfig) StopWatchingConfigFile() {
215 | cc.stopWatcherChannel <- true
216 | }
217 |
218 | func (cc *CanonicalConfig) populateFromVipers() error {
219 |
220 | // merge the slider mappings from the user and internal configs
221 | cc.SliderMapping = sliderMapFromConfigs(
222 | cc.userConfig.GetStringMapStringSlice(configKeySliderMapping),
223 | cc.internalConfig.GetStringMapStringSlice(configKeySliderMapping),
224 | )
225 |
226 | // get the rest of the config fields - viper saves us a lot of effort here
227 | cc.ConnectionInfo.COMPort = cc.userConfig.GetString(configKeyCOMPort)
228 |
229 | cc.ConnectionInfo.BaudRate = cc.userConfig.GetInt(configKeyBaudRate)
230 | if cc.ConnectionInfo.BaudRate <= 0 {
231 | cc.logger.Warnw("Invalid baud rate specified, using default value",
232 | "key", configKeyBaudRate,
233 | "invalidValue", cc.ConnectionInfo.BaudRate,
234 | "defaultValue", defaultBaudRate)
235 |
236 | cc.ConnectionInfo.BaudRate = defaultBaudRate
237 | }
238 |
239 | cc.InvertSliders = cc.userConfig.GetBool(configKeyInvertSliders)
240 | cc.NoiseReductionLevel = cc.userConfig.GetString(configKeyNoiseReductionLevel)
241 |
242 | cc.logger.Debug("Populated config fields from vipers")
243 |
244 | return nil
245 | }
246 |
247 | func (cc *CanonicalConfig) onConfigReloaded() {
248 | cc.logger.Debug("Notifying consumers about configuration reload")
249 |
250 | for _, consumer := range cc.reloadConsumers {
251 | consumer <- true
252 | }
253 | }
254 |
--------------------------------------------------------------------------------
/pkg/deej/deej.go:
--------------------------------------------------------------------------------
1 | // Package deej provides a machine-side client that pairs with an Arduino
2 | // chip to form a tactile, physical volume control system/
3 | package deej
4 |
5 | import (
6 | "errors"
7 | "fmt"
8 | "os"
9 |
10 | "go.uber.org/zap"
11 |
12 | "github.com/omriharel/deej/pkg/deej/util"
13 | )
14 |
15 | const (
16 |
17 | // when this is set to anything, deej won't use a tray icon
18 | envNoTray = "DEEJ_NO_TRAY_ICON"
19 | )
20 |
21 | // Deej is the main entity managing access to all sub-components
22 | type Deej struct {
23 | logger *zap.SugaredLogger
24 | notifier Notifier
25 | config *CanonicalConfig
26 | serial *SerialIO
27 | sessions *sessionMap
28 |
29 | stopChannel chan bool
30 | version string
31 | verbose bool
32 | }
33 |
34 | // NewDeej creates a Deej instance
35 | func NewDeej(logger *zap.SugaredLogger, verbose bool) (*Deej, error) {
36 | logger = logger.Named("deej")
37 |
38 | notifier, err := NewToastNotifier(logger)
39 | if err != nil {
40 | logger.Errorw("Failed to create ToastNotifier", "error", err)
41 | return nil, fmt.Errorf("create new ToastNotifier: %w", err)
42 | }
43 |
44 | config, err := NewConfig(logger, notifier)
45 | if err != nil {
46 | logger.Errorw("Failed to create Config", "error", err)
47 | return nil, fmt.Errorf("create new Config: %w", err)
48 | }
49 |
50 | d := &Deej{
51 | logger: logger,
52 | notifier: notifier,
53 | config: config,
54 | stopChannel: make(chan bool),
55 | verbose: verbose,
56 | }
57 |
58 | serial, err := NewSerialIO(d, logger)
59 | if err != nil {
60 | logger.Errorw("Failed to create SerialIO", "error", err)
61 | return nil, fmt.Errorf("create new SerialIO: %w", err)
62 | }
63 |
64 | d.serial = serial
65 |
66 | sessionFinder, err := newSessionFinder(logger)
67 | if err != nil {
68 | logger.Errorw("Failed to create SessionFinder", "error", err)
69 | return nil, fmt.Errorf("create new SessionFinder: %w", err)
70 | }
71 |
72 | sessions, err := newSessionMap(d, logger, sessionFinder)
73 | if err != nil {
74 | logger.Errorw("Failed to create sessionMap", "error", err)
75 | return nil, fmt.Errorf("create new sessionMap: %w", err)
76 | }
77 |
78 | d.sessions = sessions
79 |
80 | logger.Debug("Created deej instance")
81 |
82 | return d, nil
83 | }
84 |
85 | // Initialize sets up components and starts to run in the background
86 | func (d *Deej) Initialize() error {
87 | d.logger.Debug("Initializing")
88 |
89 | // load the config for the first time
90 | if err := d.config.Load(); err != nil {
91 | d.logger.Errorw("Failed to load config during initialization", "error", err)
92 | return fmt.Errorf("load config during init: %w", err)
93 | }
94 |
95 | // initialize the session map
96 | if err := d.sessions.initialize(); err != nil {
97 | d.logger.Errorw("Failed to initialize session map", "error", err)
98 | return fmt.Errorf("init session map: %w", err)
99 | }
100 |
101 | // decide whether to run with/without tray
102 | if _, noTraySet := os.LookupEnv(envNoTray); noTraySet {
103 |
104 | d.logger.Debugw("Running without tray icon", "reason", "envvar set")
105 |
106 | // run in main thread while waiting on ctrl+C
107 | d.setupInterruptHandler()
108 | d.run()
109 |
110 | } else {
111 | d.setupInterruptHandler()
112 | d.initializeTray(d.run)
113 | }
114 |
115 | return nil
116 | }
117 |
118 | // SetVersion causes deej to add a version string to its tray menu if called before Initialize
119 | func (d *Deej) SetVersion(version string) {
120 | d.version = version
121 | }
122 |
123 | // Verbose returns a boolean indicating whether deej is running in verbose mode
124 | func (d *Deej) Verbose() bool {
125 | return d.verbose
126 | }
127 |
128 | func (d *Deej) setupInterruptHandler() {
129 | interruptChannel := util.SetupCloseHandler()
130 |
131 | go func() {
132 | signal := <-interruptChannel
133 | d.logger.Debugw("Interrupted", "signal", signal)
134 | d.signalStop()
135 | }()
136 | }
137 |
138 | func (d *Deej) run() {
139 | d.logger.Info("Run loop starting")
140 |
141 | // watch the config file for changes
142 | go d.config.WatchConfigFileChanges()
143 |
144 | // connect to the arduino for the first time
145 | go func() {
146 | if err := d.serial.Start(); err != nil {
147 | d.logger.Warnw("Failed to start first-time serial connection", "error", err)
148 |
149 | // If the port is busy, that's because something else is connected - notify and quit
150 | if errors.Is(err, os.ErrPermission) {
151 | d.logger.Warnw("Serial port seems busy, notifying user and closing",
152 | "comPort", d.config.ConnectionInfo.COMPort)
153 |
154 | d.notifier.Notify(fmt.Sprintf("Can't connect to %s!", d.config.ConnectionInfo.COMPort),
155 | "This serial port is busy, make sure to close any serial monitor or other deej instance.")
156 |
157 | d.signalStop()
158 |
159 | // also notify if the COM port they gave isn't found, maybe their config is wrong
160 | } else if errors.Is(err, os.ErrNotExist) {
161 | d.logger.Warnw("Provided COM port seems wrong, notifying user and closing",
162 | "comPort", d.config.ConnectionInfo.COMPort)
163 |
164 | d.notifier.Notify(fmt.Sprintf("Can't connect to %s!", d.config.ConnectionInfo.COMPort),
165 | "This serial port doesn't exist, check your configuration and make sure it's set correctly.")
166 |
167 | d.signalStop()
168 | }
169 | }
170 | }()
171 |
172 | // wait until stopped (gracefully)
173 | <-d.stopChannel
174 | d.logger.Debug("Stop channel signaled, terminating")
175 |
176 | if err := d.stop(); err != nil {
177 | d.logger.Warnw("Failed to stop deej", "error", err)
178 | os.Exit(1)
179 | } else {
180 | // exit with 0
181 | os.Exit(0)
182 | }
183 | }
184 |
185 | func (d *Deej) signalStop() {
186 | d.logger.Debug("Signalling stop channel")
187 | d.stopChannel <- true
188 | }
189 |
190 | func (d *Deej) stop() error {
191 | d.logger.Info("Stopping")
192 |
193 | d.config.StopWatchingConfigFile()
194 | d.serial.Stop()
195 |
196 | // release the session map
197 | if err := d.sessions.release(); err != nil {
198 | d.logger.Errorw("Failed to release session map", "error", err)
199 | return fmt.Errorf("release session map: %w", err)
200 | }
201 |
202 | d.stopTray()
203 |
204 | // attempt to sync on exit - this won't necessarily work but can't harm
205 | d.logger.Sync()
206 |
207 | return nil
208 | }
209 |
--------------------------------------------------------------------------------
/pkg/deej/logger.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "fmt"
5 | "path/filepath"
6 | "time"
7 |
8 | "github.com/omriharel/deej/pkg/deej/util"
9 | "go.uber.org/zap"
10 | "go.uber.org/zap/zapcore"
11 | )
12 |
13 | const (
14 | buildTypeNone = ""
15 | buildTypeDev = "dev"
16 | buildTypeRelease = "release"
17 |
18 | logDirectory = "logs"
19 | logFilename = "deej-latest-run.log"
20 | )
21 |
22 | // NewLogger provides a logger instance for the whole program
23 | func NewLogger(buildType string) (*zap.SugaredLogger, error) {
24 | var loggerConfig zap.Config
25 |
26 | // release: info and above, log to file only (no UI)
27 | if buildType == buildTypeRelease {
28 | if err := util.EnsureDirExists(logDirectory); err != nil {
29 | return nil, fmt.Errorf("ensure log directory exists: %w", err)
30 | }
31 |
32 | loggerConfig = zap.NewProductionConfig()
33 |
34 | loggerConfig.OutputPaths = []string{filepath.Join(logDirectory, logFilename)}
35 | loggerConfig.Encoding = "console"
36 |
37 | // development: debug and above, log to stderr only, colorful
38 | } else {
39 | loggerConfig = zap.NewDevelopmentConfig()
40 |
41 | // make it colorful
42 | loggerConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
43 | }
44 |
45 | // all build types: make it readable
46 | loggerConfig.EncoderConfig.EncodeCaller = nil
47 | loggerConfig.EncoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
48 | enc.AppendString(t.Format("2006-01-02 15:04:05.000"))
49 | }
50 |
51 | loggerConfig.EncoderConfig.EncodeName = func(s string, enc zapcore.PrimitiveArrayEncoder) {
52 | enc.AppendString(fmt.Sprintf("%-27s", s))
53 | }
54 |
55 | logger, err := loggerConfig.Build()
56 | if err != nil {
57 | return nil, fmt.Errorf("create zap logger: %w", err)
58 | }
59 |
60 | // no reason not to use the sugared logger - it's fast enough for anything we're gonna do
61 | sugar := logger.Sugar()
62 |
63 | return sugar, nil
64 | }
65 |
--------------------------------------------------------------------------------
/pkg/deej/notify.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 |
7 | "github.com/gen2brain/beeep"
8 | "go.uber.org/zap"
9 |
10 | "github.com/omriharel/deej/pkg/deej/icon"
11 | "github.com/omriharel/deej/pkg/deej/util"
12 | )
13 |
14 | // Notifier provides generic notification sending
15 | type Notifier interface {
16 | Notify(title string, message string)
17 | }
18 |
19 | // ToastNotifier provides toast notifications for Windows
20 | type ToastNotifier struct {
21 | logger *zap.SugaredLogger
22 | }
23 |
24 | // NewToastNotifier creates a new ToastNotifier
25 | func NewToastNotifier(logger *zap.SugaredLogger) (*ToastNotifier, error) {
26 | logger = logger.Named("notifier")
27 | tn := &ToastNotifier{logger: logger}
28 |
29 | logger.Debug("Created toast notifier instance")
30 |
31 | return tn, nil
32 | }
33 |
34 | // Notify sends a toast notification (or falls back to other types of notification for older Windows versions)
35 | func (tn *ToastNotifier) Notify(title string, message string) {
36 |
37 | // we need to unpack deej.ico somewhere to remain portable. we already have it as bytes so it should be fine
38 | appIconPath := filepath.Join(os.TempDir(), "deej.ico")
39 |
40 | if !util.FileExists(appIconPath) {
41 | tn.logger.Debugw("Deej icon file missing, creating", "path", appIconPath)
42 |
43 | f, err := os.Create(appIconPath)
44 | if err != nil {
45 | tn.logger.Errorw("Failed to create toast notification icon", "error", err)
46 | }
47 |
48 | if _, err = f.Write(icon.DeejLogo); err != nil {
49 | tn.logger.Errorw("Failed to write toast notification icon", "error", err)
50 | }
51 |
52 | if err = f.Close(); err != nil {
53 | tn.logger.Errorw("Failed to close toast notification icon", "error", err)
54 | }
55 | }
56 |
57 | tn.logger.Infow("Sending toast notification", "title", title, "message", message)
58 |
59 | // send the actual notification
60 | if err := beeep.Notify(title, message, appIconPath); err != nil {
61 | tn.logger.Errorw("Failed to send toast notification", "error", err)
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/pkg/deej/panic.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "io/ioutil"
7 | "os"
8 | "path/filepath"
9 | "runtime/debug"
10 | "time"
11 |
12 | "github.com/omriharel/deej/pkg/deej/util"
13 | )
14 |
15 | const (
16 | crashlogFilename = "deej-crash-%s.log"
17 | crashlogTimestampFormat = "2006.01.02-15.04.05"
18 |
19 | crashMessage = `-----------------------------------------------------------------
20 | deej crashlog
21 | -----------------------------------------------------------------
22 | Unfortunately, deej has crashed. This really shouldn't happen!
23 | If you've just encountered this, please contact @omriharel and attach this error log.
24 | You can also join the deej Discord server at https://discord.gg/nf88NJu.
25 | -----------------------------------------------------------------
26 | Time: %s
27 | Panic occurred: %s
28 | Stack trace:
29 | %s
30 | -----------------------------------------------------------------
31 | `
32 | )
33 |
34 | func (d *Deej) recoverFromPanic() {
35 | r := recover()
36 |
37 | if r == nil {
38 | return
39 | }
40 |
41 | // if we got here, we're recovering from a panic!
42 | now := time.Now()
43 |
44 | // that would suck
45 | if err := util.EnsureDirExists(logDirectory); err != nil {
46 | panic(fmt.Errorf("ensure crashlog dir exists: %w", err))
47 | }
48 |
49 | crashlogBytes := bytes.NewBufferString(fmt.Sprintf(crashMessage, now.Format(crashlogTimestampFormat), r, debug.Stack()))
50 | crashlogPath := filepath.Join(logDirectory, fmt.Sprintf(crashlogFilename, now.Format(crashlogTimestampFormat)))
51 |
52 | // that would REALLY suck
53 | if err := ioutil.WriteFile(crashlogPath, crashlogBytes.Bytes(), os.ModePerm); err != nil {
54 | panic(fmt.Errorf("can't even write the crashlog file contents: %w", err))
55 | }
56 |
57 | d.logger.Errorw("Encountered and logged panic, crashing",
58 | "crashlogPath", crashlogPath,
59 | "error", r)
60 |
61 | d.notifier.Notify("Unexpected crash occurred...",
62 | fmt.Sprintf("More details in %s", crashlogPath))
63 |
64 | // bye :(
65 | d.signalStop()
66 | d.logger.Errorw("Quitting", "exitCode", 1)
67 | os.Exit(1)
68 | }
69 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/README.md:
--------------------------------------------------------------------------------
1 | ## Developer scripts
2 |
3 | This document lists the various scripts in the project and their purposes.
4 |
5 | > Note: All scripts are meant to be run from the root of the repository, i.e. from the _root_ `deej` directory: `.\pkg\deej\scripts\...\whatever.bat`. They're not guaranteed to work correctly if run from another directory.
6 |
7 | ### Windows
8 |
9 | - [`build-dev.bat`](./windows/build-dev.bat): Builds deej with a console window, for development purposes
10 | - [`build-release.bat`](./windows/build-release.bat): Builds deej as a standalone tray application without a console window, for releases
11 | - [`build-all.bat`](./windows/build-all.bat): Helper script to build all variants
12 | - [`make-icon.bat`](./windows/make-icon.bat): Converts a .ico file to an icon byte array in a Go file. Used by our systray library. You shouldn't need to run this unless you change the deej logo
13 | - [`make-rsrc.bat`](./windows/make-rsrc.bat): Generates a `rsrc.syso` resource file inside `cmd` alongside `main.go` - This indicates to the Go linker to use the deej application manifest and icon when building.
14 | - [`prepare-release.bat`](./windows/prepare-release.bat): Tags, builds and renames the release binaries in preparation for a GitHub release. Usage: `prepare-release.bat vX.Y.Z` (binaries will be under `releases\vX.Y.Z\`)
15 |
16 | ### Linux
17 |
18 | - [`build-dev.sh`](./linux/build-dev.sh): Builds deej for development purposes
19 | - [`build-release.sh`](./linux/build-release.sh): Builds deej for releases
20 | - [`build-all.sh`](./linux/build-all.sh): Helper script to build all variants
21 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/linux/build-all.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | echo 'Building deej (all)...'
4 |
5 | ./build-dev.sh
6 | ./build-release.sh
7 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/linux/build-dev.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | echo 'Building deej (development)...'
4 |
5 | # shove git commit, version tag into env
6 | GIT_COMMIT=$(git rev-list -1 --abbrev-commit HEAD)
7 | VERSION_TAG=$(git describe --tags --always)
8 | BUILD_TYPE=dev
9 | echo 'Embedding build-time parameters:'
10 | echo "- gitCommit $GIT_COMMIT"
11 | echo "- versionTag $VERSION_TAG"
12 | echo "- buildType $BUILD_TYPE"
13 |
14 | go build -o deej-dev -ldflags "-X main.gitCommit=$GIT_COMMIT -X main.versionTag=$VERSION_TAG -X main.buildType=$BUILD_TYPE" ./pkg/deej/cmd
15 | if [ $? -eq 0 ]; then
16 | echo 'Done.'
17 | else
18 | echo 'Error: "go build" exited with a non-zero code. Are you running this script from the root deej directory?'
19 | exit 1
20 | fi
21 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/linux/build-release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | echo 'Building deej (release)...'
4 |
5 | # shove git commit, version tag into env
6 | GIT_COMMIT=$(git rev-list -1 --abbrev-commit HEAD)
7 | VERSION_TAG=$(git describe --tags --always)
8 | BUILD_TYPE=release
9 | echo 'Embedding build-time parameters:'
10 | echo "- gitCommit $GIT_COMMIT"
11 | echo "- versionTag $VERSION_TAG"
12 | echo "- buildType $BUILD_TYPE"
13 |
14 | go build -o deej-release -ldflags "-s -w -X main.gitCommit=$GIT_COMMIT -X main.versionTag=$VERSION_TAG -X main.buildType=$BUILD_TYPE" ./pkg/deej/cmd
15 | if [ $? -eq 0 ]; then
16 | echo 'Done.'
17 | else
18 | echo 'Error: "go build" exited with a non-zero code. Are you running this script from the root deej directory?'
19 | exit 1
20 | fi
21 |
22 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/misc/default-config.yaml:
--------------------------------------------------------------------------------
1 | # process names are case-insensitive
2 | # you can use 'master' to indicate the master channel, or a list of process names to create a group
3 | # you can use 'mic' to control your mic input level (uses the default recording device)
4 | # you can use 'deej.unmapped' to control all apps that aren't bound to any slider (this ignores master, system, mic and device-targeting sessions)
5 | # windows only - you can use 'deej.current' to control the currently active app (whether full-screen or not)
6 | # windows only - you can use a device's full name, i.e. "Speakers (Realtek High Definition Audio)", to bind it. this works for both output and input devices
7 | # windows only - you can use 'system' to control the "system sounds" volume
8 | # important: slider indexes start at 0, regardless of which analog pins you're using!
9 | slider_mapping:
10 | 0: master
11 | 1: chrome.exe
12 | 2: spotify.exe
13 | 3:
14 | - pathofexile_x64.exe
15 | - rocketleague.exe
16 | 4: discord.exe
17 |
18 | # set this to true if you want the controls inverted (i.e. top is 0%, bottom is 100%)
19 | invert_sliders: false
20 |
21 | # settings for connecting to the arduino board
22 | com_port: COM4
23 | baud_rate: 9600
24 |
25 | # adjust the amount of signal noise reduction depending on your hardware quality
26 | # supported values are "low" (excellent hardware), "default" (regular hardware) or "high" (bad, noisy hardware)
27 | noise_reduction: default
28 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/misc/release-notes.txt:
--------------------------------------------------------------------------------
1 | This release ___________:
2 |
3 | - One awesome thing that happened
4 | - Another awesome thing
5 |
6 | **Launch instructions**
7 |
8 | - **First-time users:** download the [Arduino sketch](https://github.com/omriharel/deej/blob/master/arduino/deej-5-sliders-vanilla/deej-5-sliders-vanilla.ino) and upload it to your board ([see full instructions here](https://github.com/omriharel/deej#build-procedure))
9 | - Download both `config.yaml` and `deej.exe` (below, under _Assets_) and place them in the same directory
10 | - Verify that your config has the correct COM port for your Arduino board
11 | - Launch `deej.exe`. You can always edit the config while deej is running
12 |
13 | **_Linux users:_** for the time being, please build from source. You'll need `libgtk-3-dev`, `libappindicator3-dev` and `libwebkit2gtk-4.0-dev` for system tray support. If there's demand for precompiled release binaries, please [let us know!](https://discord.gg/nf88NJu)
14 |
15 | > _Tip:_ If `deej.exe` seems to crash or doesn't start, please [send us](https://discord.gg/nf88NJu) the `logs\deej-latest-run.log` file from your `deej` directory.
16 |
17 | > _Tip:_ `deej-debug.exe` is a version with a console window that displays additional logs. If you have trouble getting things to work, [join our Discord](https://discord.gg/nf88NJu) and post a snippet there.
18 |
19 | More detailed instructions and documentation are in the [repo's main page](https://github.com/omriharel/deej).
20 |
21 | Enjoy!
22 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/windows/build-all.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | ECHO Building deej (all)...
4 |
5 | REM set windows scripts dir root in relation to script path to avoid cwd dependency
6 | SET "WIN_SCRIPTS_ROOT=%~dp0"
7 |
8 | CALL "%WIN_SCRIPTS_ROOT%build-dev.bat"
9 | CALL "%WIN_SCRIPTS_ROOT%build-release.bat"
10 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/windows/build-dev.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | ECHO Building deej (development)...
4 |
5 | REM set repo root in relation to script path to avoid cwd dependency
6 | SET "DEEJ_ROOT=%~dp0..\..\..\.."
7 |
8 | REM shove git commit, version tag into env
9 | for /f "delims=" %%a in ('git rev-list -1 --abbrev-commit HEAD') do @set GIT_COMMIT=%%a
10 | for /f "delims=" %%a in ('git describe --tags --always') do @set VERSION_TAG=%%a
11 | set BUILD_TYPE=dev
12 | ECHO Embedding build-time parameters:
13 | ECHO - gitCommit %GIT_COMMIT%
14 | ECHO - versionTag %VERSION_TAG%
15 | ECHO - buildType %BUILD_TYPE%
16 |
17 | go build -o "%DEEJ_ROOT%\deej-dev.exe" -ldflags "-X main.gitCommit=%GIT_COMMIT% -X main.versionTag=%VERSION_TAG% -X main.buildType=%BUILD_TYPE%" "%DEEJ_ROOT%\pkg\deej\cmd"
18 | if %ERRORLEVEL% NEQ 0 GOTO BUILDERROR
19 | ECHO Done.
20 | GOTO DONE
21 |
22 | :BUILDERROR
23 | ECHO Failed to build deej in development mode! See above output for details.
24 | EXIT /B 1
25 |
26 | :DONE
27 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/windows/build-release.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | ECHO Building deej (release)...
4 |
5 | REM set repo root in relation to script path to avoid cwd dependency
6 | SET "DEEJ_ROOT=%~dp0..\..\..\.."
7 |
8 | REM shove git commit, version tag into env
9 | for /f "delims=" %%a in ('git rev-list -1 --abbrev-commit HEAD') do @set GIT_COMMIT=%%a
10 | for /f "delims=" %%a in ('git describe --tags --always') do @set VERSION_TAG=%%a
11 | set BUILD_TYPE=release
12 | ECHO Embedding build-time parameters:
13 | ECHO - gitCommit %GIT_COMMIT%
14 | ECHO - versionTag %VERSION_TAG%
15 | ECHO - buildType %BUILD_TYPE%
16 |
17 | go build -o "%DEEJ_ROOT%\deej-release.exe" -ldflags "-H=windowsgui -s -w -X main.gitCommit=%GIT_COMMIT% -X main.versionTag=%VERSION_TAG% -X main.buildType=%BUILD_TYPE%" "%DEEJ_ROOT%\pkg\deej\cmd"
18 | IF %ERRORLEVEL% NEQ 0 GOTO BUILDERROR
19 | ECHO Done.
20 | GOTO DONE
21 |
22 | :BUILDERROR
23 | ECHO Failed to build deej in release mode! See above output for details.
24 | EXIT /B 1
25 |
26 | :DONE
27 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/windows/make-icon.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | IF "%GOPATH%"=="" GOTO NOGO
4 | IF NOT EXIST %GOPATH%\bin\2goarray.exe GOTO INSTALL
5 | :POSTINSTALL
6 | IF "%1"=="" GOTO NOICO
7 | IF NOT EXIST %1 GOTO BADFILE
8 | ECHO Creating iconwin.go
9 | TYPE %1 | %GOPATH%\bin\2goarray Data icon >> iconwin.go
10 | GOTO DONE
11 |
12 | :CREATEFAIL
13 | ECHO Unable to create output file
14 | GOTO DONE
15 |
16 | :INSTALL
17 | ECHO Installing 2goarray...
18 | go get github.com/cratonica/2goarray
19 | IF ERRORLEVEL 1 GOTO GETFAIL
20 | GOTO POSTINSTALL
21 |
22 | :GETFAIL
23 | ECHO Failure running go get github.com/cratonica/2goarray. Ensure that go and git are in PATH
24 | GOTO DONE
25 |
26 | :NOGO
27 | ECHO GOPATH environment variable not set
28 | GOTO DONE
29 |
30 | :NOICO
31 | ECHO Please specify a .ico file
32 | GOTO DONE
33 |
34 | :BADFILE
35 | ECHO %1 is not a valid file
36 | GOTO DONE
37 |
38 | :DONE
39 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/windows/make-rsrc.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | IF "%GOPATH%"=="" GOTO NOGO
4 | IF NOT EXIST %GOPATH%\bin\rsrc.exe GOTO INSTALL
5 | :POSTINSTALL
6 | ECHO Creating pkg/deej/cmd/rsrc.syso
7 | %GOPATH%\bin\rsrc -manifest pkg\deej\assets\deej.manifest -ico pkg\deej\assets\logo.ico -o pkg\deej\cmd\rsrc_windows.syso
8 | GOTO DONE
9 |
10 | :INSTALL
11 | ECHO Installing rsrc...
12 | go get github.com/akavel/rsrc
13 | IF ERRORLEVEL 1 GOTO GETFAIL
14 | GOTO POSTINSTALL
15 |
16 | :GETFAIL
17 | ECHO Failure running go get github.com/akavel/rsrc. Ensure that go and git are in PATH
18 | GOTO DONE
19 |
20 | :NOGO
21 | ECHO GOPATH environment variable not set
22 | GOTO DONE
23 |
24 | :DONE
25 |
--------------------------------------------------------------------------------
/pkg/deej/scripts/windows/prepare-release.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | IF "%1"=="" GOTO NOTAG
4 |
5 | ECHO Preparing release (%1)...
6 | ECHO.
7 |
8 | git tag --delete %1 >NUL 2>&1
9 | git tag %1
10 |
11 | REM set windows scripts dir root in relation to script path to avoid cwd dependency
12 | SET "WIN_SCRIPTS_ROOT=%~dp0"
13 |
14 | CALL "%WIN_SCRIPTS_ROOT%build-dev.bat"
15 |
16 | ECHO.
17 |
18 | CALL "%WIN_SCRIPTS_ROOT%build-release.bat"
19 |
20 | REM make this next part nicer by setting the repo root
21 | SET "DEEJ_ROOT=%WIN_SCRIPTS_ROOT%..\..\..\.."
22 | PUSHD "%DEEJ_ROOT%"
23 | SET "DEEJ_ROOT=%CD%"
24 | POPD
25 |
26 | MKDIR "%DEEJ_ROOT%\releases\%1" 2> NUL
27 | MOVE /Y "%DEEJ_ROOT%\deej-release.exe" "%DEEJ_ROOT%\releases\%1\deej.exe" >NUL 2>&1
28 | MOVE /Y "%DEEJ_ROOT%\deej-dev.exe" "%DEEJ_ROOT%\releases\%1\deej-debug.exe" >NUL 2>&1
29 | COPY /Y "%DEEJ_ROOT%\pkg\deej\scripts\misc\default-config.yaml" "%DEEJ_ROOT%\releases\%1\config.yaml" >NUL 2>&1
30 | COPY /Y "%DEEJ_ROOT%\pkg\deej\scripts\misc\release-notes.txt" "%DEEJ_ROOT%\releases\%1\notes.txt" >NUL 2>&1
31 |
32 | ECHO.
33 | ECHO Release binaries created in %DEEJ_ROOT%\releases\%1
34 | ECHO Opening release directory and notes for editing.
35 | ECHO When you're done, run "git push origin %1" and draft the release on GitHub.
36 |
37 | START explorer.exe "%DEEJ_ROOT%\releases\%1"
38 | START notepad.exe "%DEEJ_ROOT%\releases\%1\notes.txt"
39 |
40 | GOTO DONE
41 |
42 | :NOTAG
43 | ECHO usage: %0 ^<tag name^> (use semver i.e. v0.9.3)
44 | GOTO DONE
45 |
46 | :DONE
47 |
--------------------------------------------------------------------------------
/pkg/deej/serial.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "bufio"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "regexp"
9 | "strconv"
10 | "strings"
11 | "time"
12 |
13 | "github.com/jacobsa/go-serial/serial"
14 | "go.uber.org/zap"
15 |
16 | "github.com/omriharel/deej/pkg/deej/util"
17 | )
18 |
19 | // SerialIO provides a deej-aware abstraction layer to managing serial I/O
20 | type SerialIO struct {
21 | comPort string
22 | baudRate uint
23 |
24 | deej *Deej
25 | logger *zap.SugaredLogger
26 |
27 | stopChannel chan bool
28 | connected bool
29 | connOptions serial.OpenOptions
30 | conn io.ReadWriteCloser
31 |
32 | lastKnownNumSliders int
33 | currentSliderPercentValues []float32
34 |
35 | sliderMoveConsumers []chan SliderMoveEvent
36 | }
37 |
38 | // SliderMoveEvent represents a single slider move captured by deej
39 | type SliderMoveEvent struct {
40 | SliderID int
41 | PercentValue float32
42 | }
43 |
44 | var expectedLinePattern = regexp.MustCompile(`^\d{1,4}(\|\d{1,4})*\r\n
max tokens
The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by
removing the max tokens filter.
)
45 |
46 | // NewSerialIO creates a SerialIO instance that uses the provided deej
47 | // instance's connection info to establish communications with the arduino chip
48 | func NewSerialIO(deej *Deej, logger *zap.SugaredLogger) (*SerialIO, error) {
49 | logger = logger.Named("serial")
50 |
51 | sio := &SerialIO{
52 | deej: deej,
53 | logger: logger,
54 | stopChannel: make(chan bool),
55 | connected: false,
56 | conn: nil,
57 | sliderMoveConsumers: []chan SliderMoveEvent{},
58 | }
59 |
60 | logger.Debug("Created serial i/o instance")
61 |
62 | // respond to config changes
63 | sio.setupOnConfigReload()
64 |
65 | return sio, nil
66 | }
67 |
68 | // Start attempts to connect to our arduino chip
69 | func (sio *SerialIO) Start() error {
70 |
71 | // don't allow multiple concurrent connections
72 | if sio.connected {
73 | sio.logger.Warn("Already connected, can't start another without closing first")
74 | return errors.New("serial: connection already active")
75 | }
76 |
77 | // set minimum read size according to platform (0 for windows, 1 for linux)
78 | // this prevents a rare bug on windows where serial reads get congested,
79 | // resulting in significant lag
80 | minimumReadSize := 0
81 | if util.Linux() {
82 | minimumReadSize = 1
83 | }
84 |
85 | sio.connOptions = serial.OpenOptions{
86 | PortName: sio.deej.config.ConnectionInfo.COMPort,
87 | BaudRate: uint(sio.deej.config.ConnectionInfo.BaudRate),
88 | DataBits: 8,
89 | StopBits: 1,
90 | MinimumReadSize: uint(minimumReadSize),
91 | }
92 |
93 | sio.logger.Debugw("Attempting serial connection",
94 | "comPort", sio.connOptions.PortName,
95 | "baudRate", sio.connOptions.BaudRate,
96 | "minReadSize", minimumReadSize)
97 |
98 | var err error
99 | sio.conn, err = serial.Open(sio.connOptions)
100 | if err != nil {
101 |
102 | // might need a user notification here, TBD
103 | sio.logger.Warnw("Failed to open serial connection", "error", err)
104 | return fmt.Errorf("open serial connection: %w", err)
105 | }
106 |
107 | namedLogger := sio.logger.Named(strings.ToLower(sio.connOptions.PortName))
108 |
109 | namedLogger.Infow("Connected", "conn", sio.conn)
110 | sio.connected = true
111 |
112 | // read lines or await a stop
113 | go func() {
114 | connReader := bufio.NewReader(sio.conn)
115 | lineChannel := sio.readLine(namedLogger, connReader)
116 |
117 | for {
118 | select {
119 | case <-sio.stopChannel:
120 | sio.close(namedLogger)
121 | case line := <-lineChannel:
122 | sio.handleLine(namedLogger, line)
123 | }
124 | }
125 | }()
126 |
127 | return nil
128 | }
129 |
130 | // Stop signals us to shut down our serial connection, if one is active
131 | func (sio *SerialIO) Stop() {
132 | if sio.connected {
133 | sio.logger.Debug("Shutting down serial connection")
134 | sio.stopChannel <- true
135 | } else {
136 | sio.logger.Debug("Not currently connected, nothing to stop")
137 | }
138 | }
139 |
140 | // SubscribeToSliderMoveEvents returns an unbuffered channel that receives
141 | // a sliderMoveEvent struct every time a slider moves
142 | func (sio *SerialIO) SubscribeToSliderMoveEvents() chan SliderMoveEvent {
143 | ch := make(chan SliderMoveEvent)
144 | sio.sliderMoveConsumers = append(sio.sliderMoveConsumers, ch)
145 |
146 | return ch
147 | }
148 |
149 | func (sio *SerialIO) setupOnConfigReload() {
150 | configReloadedChannel := sio.deej.config.SubscribeToChanges()
151 |
152 | const stopDelay = 50 * time.Millisecond
153 |
154 | go func() {
155 | for {
156 | select {
157 | case <-configReloadedChannel:
158 |
159 | // make any config reload unset our slider number to ensure process volumes are being re-set
160 | // (the next read line will emit SliderMoveEvent instances for all sliders)\
161 | // this needs to happen after a small delay, because the session map will also re-acquire sessions
162 | // whenever the config file is reloaded, and we don't want it to receive these move events while the map
163 | // is still cleared. this is kind of ugly, but shouldn't cause any issues
164 | go func() {
165 | <-time.After(stopDelay)
166 | sio.lastKnownNumSliders = 0
167 | }()
168 |
169 | // if connection params have changed, attempt to stop and start the connection
170 | if sio.deej.config.ConnectionInfo.COMPort != sio.connOptions.PortName ||
171 | uint(sio.deej.config.ConnectionInfo.BaudRate) != sio.connOptions.BaudRate {
172 |
173 | sio.logger.Info("Detected change in connection parameters, attempting to renew connection")
174 | sio.Stop()
175 |
176 | // let the connection close
177 | <-time.After(stopDelay)
178 |
179 | if err := sio.Start(); err != nil {
180 | sio.logger.Warnw("Failed to renew connection after parameter change", "error", err)
181 | } else {
182 | sio.logger.Debug("Renewed connection successfully")
183 | }
184 | }
185 | }
186 | }
187 | }()
188 | }
189 |
190 | func (sio *SerialIO) close(logger *zap.SugaredLogger) {
191 | if err := sio.conn.Close(); err != nil {
192 | logger.Warnw("Failed to close serial connection", "error", err)
193 | } else {
194 | logger.Debug("Serial connection closed")
195 | }
196 |
197 | sio.conn = nil
198 | sio.connected = false
199 | }
200 |
201 | func (sio *SerialIO) readLine(logger *zap.SugaredLogger, reader *bufio.Reader) chan string {
202 | ch := make(chan string)
203 |
204 | go func() {
205 | for {
206 | line, err := reader.ReadString('\n')
207 | if err != nil {
208 |
209 | if sio.deej.Verbose() {
210 | logger.Warnw("Failed to read line from serial", "error", err, "line", line)
211 | }
212 |
213 | // just ignore the line, the read loop will stop after this
214 | return
215 | }
216 |
217 | if sio.deej.Verbose() {
218 | logger.Debugw("Read new line", "line", line)
219 | }
220 |
221 | // deliver the line to the channel
222 | ch <- line
223 | }
224 | }()
225 |
226 | return ch
227 | }
228 |
229 | func (sio *SerialIO) handleLine(logger *zap.SugaredLogger, line string) {
230 |
231 | // this function receives an unsanitized line which is guaranteed to end with LF,
232 | // but most lines will end with CRLF. it may also have garbage instead of
233 | // deej-formatted values, so we must check for that! just ignore bad ones
234 | if !expectedLinePattern.MatchString(line) {
235 | return
236 | }
237 |
238 | // trim the suffix
239 | line = strings.TrimSuffix(line, "\r\n")
240 |
241 | // split on pipe (|), this gives a slice of numerical strings between "0" and "1023"
242 | splitLine := strings.Split(line, "|")
243 | numSliders := len(splitLine)
244 |
245 | // update our slider count, if needed - this will send slider move events for all
246 | if numSliders != sio.lastKnownNumSliders {
247 | logger.Infow("Detected sliders", "amount", numSliders)
248 | sio.lastKnownNumSliders = numSliders
249 | sio.currentSliderPercentValues = make([]float32, numSliders)
250 |
251 | // reset everything to be an impossible value to force the slider move event later
252 | for idx := range sio.currentSliderPercentValues {
253 | sio.currentSliderPercentValues[idx] = -1.0
254 | }
255 | }
256 |
257 | // for each slider:
258 | moveEvents := []SliderMoveEvent{}
259 | for sliderIdx, stringValue := range splitLine {
260 |
261 | // convert string values to integers ("1023" -> 1023)
262 | number, _ := strconv.Atoi(stringValue)
263 |
264 | // turns out the first line could come out dirty sometimes (i.e. "4558|925|41|643|220")
265 | // so let's check the first number for correctness just in case
266 | if sliderIdx == 0 && number > 1023 {
267 | sio.logger.Debugw("Got malformed line from serial, ignoring", "line", line)
268 | return
269 | }
270 |
271 | // map the value from raw to a "dirty" float between 0 and 1 (e.g. 0.15451...)
272 | dirtyFloat := float32(number) / 1023.0
273 |
274 | // normalize it to an actual volume scalar between 0.0 and 1.0 with 2 points of precision
275 | normalizedScalar := util.NormalizeScalar(dirtyFloat)
276 |
277 | // if sliders are inverted, take the complement of 1.0
278 | if sio.deej.config.InvertSliders {
279 | normalizedScalar = 1 - normalizedScalar
280 | }
281 |
282 | // check if it changes the desired state (could just be a jumpy raw slider value)
283 | if util.SignificantlyDifferent(sio.currentSliderPercentValues[sliderIdx], normalizedScalar, sio.deej.config.NoiseReductionLevel) {
284 |
285 | // if it does, update the saved value and create a move event
286 | sio.currentSliderPercentValues[sliderIdx] = normalizedScalar
287 |
288 | moveEvents = append(moveEvents, SliderMoveEvent{
289 | SliderID: sliderIdx,
290 | PercentValue: normalizedScalar,
291 | })
292 |
293 | if sio.deej.Verbose() {
294 | logger.Debugw("Slider moved", "event", moveEvents[len(moveEvents)-1])
295 | }
296 | }
297 | }
298 |
299 | // deliver move events if there are any, towards all potential consumers
300 | if len(moveEvents) > 0 {
301 | for _, consumer := range sio.sliderMoveConsumers {
302 | for _, moveEvent := range moveEvents {
303 | consumer <- moveEvent
304 | }
305 | }
306 | }
307 | }
308 |
--------------------------------------------------------------------------------
/pkg/deej/session.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "strings"
5 |
6 | "go.uber.org/zap"
7 | )
8 |
9 | // Session represents a single addressable audio session
10 | type Session interface {
11 | GetVolume() float32
12 | SetVolume(v float32) error
13 |
14 | // TODO: future mute support
15 | // GetMute() bool
16 | // SetMute(m bool) error
17 |
18 | Key() string
19 | Release()
20 | }
21 |
22 | const (
23 |
24 | // ideally these would share a common ground in baseSession
25 | // but it will not call the child GetVolume correctly :/
26 | sessionCreationLogMessage = "Created audio session instance"
27 |
28 | // format this with s.humanReadableDesc and whatever the current volume is
29 | sessionStringFormat = "<session: %s, vol: %.2f>"
30 | )
31 |
32 | type baseSession struct {
33 | logger *zap.SugaredLogger
34 | system bool
35 | master bool
36 |
37 | // used by Key(), needs to be set by child
38 | name string
39 |
40 | // used by String(), needs to be set by child
41 | humanReadableDesc string
42 | }
43 |
44 | func (s *baseSession) Key() string {
45 | if s.system {
46 | return systemSessionName
47 | }
48 |
49 | if s.master {
50 | return strings.ToLower(s.name) // could be master or mic, or any device's friendly name
51 | }
52 |
53 | return strings.ToLower(s.name)
54 | }
55 |
--------------------------------------------------------------------------------
/pkg/deej/session_finder.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | // SessionFinder represents an entity that can find all current audio sessions
4 | type SessionFinder interface {
5 | GetAllSessions() ([]Session, error)
6 |
7 | Release() error
8 | }
9 |
--------------------------------------------------------------------------------
/pkg/deej/session_finder_linux.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "fmt"
5 | "net"
6 |
7 | "github.com/jfreymuth/pulse/proto"
8 | "go.uber.org/zap"
9 | )
10 |
11 | type paSessionFinder struct {
12 | logger *zap.SugaredLogger
13 | sessionLogger *zap.SugaredLogger
14 |
15 | client *proto.Client
16 | conn net.Conn
17 | }
18 |
19 | func newSessionFinder(logger *zap.SugaredLogger) (SessionFinder, error) {
20 | client, conn, err := proto.Connect("")
21 | if err != nil {
22 | logger.Warnw("Failed to establish PulseAudio connection", "error", err)
23 | return nil, fmt.Errorf("establish PulseAudio connection: %w", err)
24 | }
25 |
26 | request := proto.SetClientName{
27 | Props: proto.PropList{
28 | "application.name": proto.PropListString("deej"),
29 | },
30 | }
31 | reply := proto.SetClientNameReply{}
32 |
33 | if err := client.Request(&request, &reply); err != nil {
34 | return nil, err
35 | }
36 |
37 | sf := &paSessionFinder{
38 | logger: logger.Named("session_finder"),
39 | sessionLogger: logger.Named("sessions"),
40 | client: client,
41 | conn: conn,
42 | }
43 |
44 | sf.logger.Debug("Created PA session finder instance")
45 |
46 | return sf, nil
47 | }
48 |
49 | func (sf *paSessionFinder) GetAllSessions() ([]Session, error) {
50 | sessions := []Session{}
51 |
52 | // get the master sink session
53 | masterSink, err := sf.getMasterSinkSession()
54 | if err == nil {
55 | sessions = append(sessions, masterSink)
56 | } else {
57 | sf.logger.Warnw("Failed to get master audio sink session", "error", err)
58 | }
59 |
60 | // get the master source session
61 | masterSource, err := sf.getMasterSourceSession()
62 | if err == nil {
63 | sessions = append(sessions, masterSource)
64 | } else {
65 | sf.logger.Warnw("Failed to get master audio source session", "error", err)
66 | }
67 |
68 | // enumerate sink inputs and add sessions along the way
69 | if err := sf.enumerateAndAddSessions(&sessions); err != nil {
70 | sf.logger.Warnw("Failed to enumerate audio sessions", "error", err)
71 | return nil, fmt.Errorf("enumerate audio sessions: %w", err)
72 | }
73 |
74 | return sessions, nil
75 | }
76 |
77 | func (sf *paSessionFinder) Release() error {
78 | if err := sf.conn.Close(); err != nil {
79 | sf.logger.Warnw("Failed to close PulseAudio connection", "error", err)
80 | return fmt.Errorf("close PulseAudio connection: %w", err)
81 | }
82 |
83 | sf.logger.Debug("Released PA session finder instance")
84 |
85 | return nil
86 | }
87 |
88 | func (sf *paSessionFinder) getMasterSinkSession() (Session, error) {
89 | request := proto.GetSinkInfo{
90 | SinkIndex: proto.Undefined,
91 | }
92 | reply := proto.GetSinkInfoReply{}
93 |
94 | if err := sf.client.Request(&request, &reply); err != nil {
95 | sf.logger.Warnw("Failed to get master sink info", "error", err)
96 | return nil, fmt.Errorf("get master sink info: %w", err)
97 | }
98 |
99 | // create the master sink session
100 | sink := newMasterSession(sf.sessionLogger, sf.client, reply.SinkIndex, reply.Channels, true)
101 |
102 | return sink, nil
103 | }
104 |
105 | func (sf *paSessionFinder) getMasterSourceSession() (Session, error) {
106 | request := proto.GetSourceInfo{
107 | SourceIndex: proto.Undefined,
108 | }
109 | reply := proto.GetSourceInfoReply{}
110 |
111 | if err := sf.client.Request(&request, &reply); err != nil {
112 | sf.logger.Warnw("Failed to get master source info", "error", err)
113 | return nil, fmt.Errorf("get master source info: %w", err)
114 | }
115 |
116 | // create the master source session
117 | source := newMasterSession(sf.sessionLogger, sf.client, reply.SourceIndex, reply.Channels, false)
118 |
119 | return source, nil
120 | }
121 |
122 | func (sf *paSessionFinder) enumerateAndAddSessions(sessions *[]Session) error {
123 | request := proto.GetSinkInputInfoList{}
124 | reply := proto.GetSinkInputInfoListReply{}
125 |
126 | if err := sf.client.Request(&request, &reply); err != nil {
127 | sf.logger.Warnw("Failed to get sink input list", "error", err)
128 | return fmt.Errorf("get sink input list: %w", err)
129 | }
130 |
131 | for _, info := range reply {
132 | name, ok := info.Properties["application.process.binary"]
133 |
134 | if !ok {
135 | sf.logger.Warnw("Failed to get sink input's process name",
136 | "sinkInputIndex", info.SinkInputIndex)
137 |
138 | continue
139 | }
140 |
141 | // create the deej session object
142 | newSession := newPASession(sf.sessionLogger, sf.client, info.SinkInputIndex, info.Channels, name.String())
143 |
144 | // add it to our slice
145 | *sessions = append(*sessions, newSession)
146 |
147 | }
148 |
149 | return nil
150 | }
151 |
--------------------------------------------------------------------------------
/pkg/deej/session_finder_windows.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 | "syscall"
8 | "time"
9 | "unsafe"
10 |
11 | ole "github.com/go-ole/go-ole"
12 | wca "github.com/moutend/go-wca"
13 | "go.uber.org/zap"
14 | )
15 |
16 | type wcaSessionFinder struct {
17 | logger *zap.SugaredLogger
18 | sessionLogger *zap.SugaredLogger
19 |
20 | eventCtx *ole.GUID // needed for some session actions to successfully notify other audio consumers
21 |
22 | // needed for device change notifications
23 | mmDeviceEnumerator *wca.IMMDeviceEnumerator
24 | mmNotificationClient *wca.IMMNotificationClient
25 | lastDefaultDeviceChange time.Time
26 |
27 | // our master input and output sessions
28 | masterOut *masterSession
29 | masterIn *masterSession
30 | }
31 |
32 | const (
33 |
34 | // there's no real mystery here, it's just a random GUID
35 | myteriousGUID = "{1ec920a1-7db8-44ba-9779-e5d28ed9f330}"
36 |
37 | // the notification client will call this multiple times in quick succession based on the
38 | // default device's assigned media roles, so we need to filter out the extraneous calls
39 | minDefaultDeviceChangeThreshold = 100 * time.Millisecond
40 |
41 | // prefix for device sessions in logger
42 | deviceSessionFormat = "device.%s"
43 | )
44 |
45 | func newSessionFinder(logger *zap.SugaredLogger) (SessionFinder, error) {
46 | sf := &wcaSessionFinder{
47 | logger: logger.Named("session_finder"),
48 | sessionLogger: logger.Named("sessions"),
49 | eventCtx: ole.NewGUID(myteriousGUID),
50 | }
51 |
52 | sf.logger.Debug("Created WCA session finder instance")
53 |
54 | return sf, nil
55 | }
56 |
57 | func (sf *wcaSessionFinder) GetAllSessions() ([]Session, error) {
58 | sessions := []Session{}
59 |
60 | // we must call this every time we're about to list devices, i think. could be wrong
61 | if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
62 |
63 | // if the error is "Incorrect function" that corresponds to 0x00000001,
64 | // which represents E_FALSE in COM error handling. this is fine for this function,
65 | // and just means that the call was redundant.
66 | const eFalse = 1
67 | oleError := &ole.OleError{}
68 |
69 | if errors.As(err, &oleError) {
70 | if oleError.Code() == eFalse {
71 | sf.logger.Warn("CoInitializeEx failed with E_FALSE due to redundant invocation")
72 | } else {
73 | sf.logger.Warnw("Failed to call CoInitializeEx",
74 | "isOleError", true,
75 | "error", err,
76 | "oleError", oleError)
77 |
78 | return nil, fmt.Errorf("call CoInitializeEx: %w", err)
79 | }
80 | } else {
81 | sf.logger.Warnw("Failed to call CoInitializeEx",
82 | "isOleError", false,
83 | "error", err,
84 | "oleError", nil)
85 |
86 | return nil, fmt.Errorf("call CoInitializeEx: %w", err)
87 | }
88 |
89 | }
90 | defer ole.CoUninitialize()
91 |
92 | // ensure we have a device enumerator
93 | if err := sf.getDeviceEnumerator(); err != nil {
94 | sf.logger.Warnw("Failed to get device enumerator", "error", err)
95 | return nil, fmt.Errorf("get device enumerator: %w", err)
96 | }
97 |
98 | // get the currently active default output and input devices.
99 | // please note that this can return a nil defaultInputEndpoint, in case there are no input devices connected.
100 | // you must check it for non-nil
101 | defaultOutputEndpoint, defaultInputEndpoint, err := sf.getDefaultAudioEndpoints()
102 | if err != nil {
103 | sf.logger.Warnw("Failed to get default audio endpoints", "error", err)
104 | return nil, fmt.Errorf("get default audio endpoints: %w", err)
105 | }
106 | defer defaultOutputEndpoint.Release()
107 |
108 | if defaultInputEndpoint != nil {
109 | defer defaultInputEndpoint.Release()
110 | }
111 |
112 | // receive notifications whenever the default device changes (only do this once)
113 | if sf.mmNotificationClient == nil {
114 | if err := sf.registerDefaultDeviceChangeCallback(); err != nil {
115 | sf.logger.Warnw("Failed to register default device change callback", "error", err)
116 | return nil, fmt.Errorf("register default device change callback: %w", err)
117 | }
118 | }
119 |
120 | // get the master output session
121 | sf.masterOut, err = sf.getMasterSession(defaultOutputEndpoint, masterSessionName, masterSessionName)
122 | if err != nil {
123 | sf.logger.Warnw("Failed to get master audio output session", "error", err)
124 | return nil, fmt.Errorf("get master audio output session: %w", err)
125 | }
126 |
127 | sessions = append(sessions, sf.masterOut)
128 |
129 | // get the master input session, if a default input device exists
130 | if defaultInputEndpoint != nil {
131 | sf.masterIn, err = sf.getMasterSession(defaultInputEndpoint, inputSessionName, inputSessionName)
132 | if err != nil {
133 | sf.logger.Warnw("Failed to get master audio input session", "error", err)
134 | return nil, fmt.Errorf("get master audio input session: %w", err)
135 | }
136 |
137 | sessions = append(sessions, sf.masterIn)
138 | }
139 |
140 | // enumerate all devices and make their "master" sessions bindable by friendly name;
141 | // for output devices, this is also where we enumerate process sessions
142 | if err := sf.enumerateAndAddSessions(&sessions); err != nil {
143 | sf.logger.Warnw("Failed to enumerate device sessions", "error", err)
144 | return nil, fmt.Errorf("enumerate device sessions: %w", err)
145 | }
146 |
147 | return sessions, nil
148 | }
149 |
150 | func (sf *wcaSessionFinder) Release() error {
151 |
152 | // skip unregistering the mmnotificationclient, as it's not implemented in go-wca
153 | if sf.mmDeviceEnumerator != nil {
154 | sf.mmDeviceEnumerator.Release()
155 | }
156 |
157 | sf.logger.Debug("Released WCA session finder instance")
158 |
159 | return nil
160 | }
161 |
162 | func (sf *wcaSessionFinder) getDeviceEnumerator() error {
163 |
164 | // get the IMMDeviceEnumerator (only once)
165 | if sf.mmDeviceEnumerator == nil {
166 | if err := wca.CoCreateInstance(
167 | wca.CLSID_MMDeviceEnumerator,
168 | 0,
169 | wca.CLSCTX_ALL,
170 | wca.IID_IMMDeviceEnumerator,
171 | &sf.mmDeviceEnumerator,
172 | ); err != nil {
173 | sf.logger.Warnw("Failed to call CoCreateInstance", "error", err)
174 | return fmt.Errorf("call CoCreateInstance: %w", err)
175 | }
176 | }
177 |
178 | return nil
179 | }
180 |
181 | func (sf *wcaSessionFinder) getDefaultAudioEndpoints() (*wca.IMMDevice, *wca.IMMDevice, error) {
182 |
183 | // get the default audio endpoints as IMMDevice instances
184 | var mmOutDevice *wca.IMMDevice
185 | var mmInDevice *wca.IMMDevice
186 |
187 | if err := sf.mmDeviceEnumerator.GetDefaultAudioEndpoint(wca.ERender, wca.EConsole, &mmOutDevice); err != nil {
188 | sf.logger.Warnw("Failed to call GetDefaultAudioEndpoint (out)", "error", err)
189 | return nil, nil, fmt.Errorf("call GetDefaultAudioEndpoint (out): %w", err)
190 | }
191 |
192 | // allow this call to fail (not all users have a microphone connected)
193 | if err := sf.mmDeviceEnumerator.GetDefaultAudioEndpoint(wca.ECapture, wca.EConsole, &mmInDevice); err != nil {
194 | sf.logger.Warn("No default input device detected, proceeding without it (\"mic\" will not work)")
195 | mmInDevice = nil
196 | }
197 |
198 | return mmOutDevice, mmInDevice, nil
199 | }
200 |
201 | func (sf *wcaSessionFinder) registerDefaultDeviceChangeCallback() error {
202 | sf.mmNotificationClient = &wca.IMMNotificationClient{}
203 | sf.mmNotificationClient.VTable = &wca.IMMNotificationClientVtbl{}
204 |
205 | // fill the VTable with noops, except for OnDefaultDeviceChanged. that one's gold
206 | sf.mmNotificationClient.VTable.QueryInterface = syscall.NewCallback(sf.noopCallback)
207 | sf.mmNotificationClient.VTable.AddRef = syscall.NewCallback(sf.noopCallback)
208 | sf.mmNotificationClient.VTable.Release = syscall.NewCallback(sf.noopCallback)
209 | sf.mmNotificationClient.VTable.OnDeviceStateChanged = syscall.NewCallback(sf.noopCallback)
210 | sf.mmNotificationClient.VTable.OnDeviceAdded = syscall.NewCallback(sf.noopCallback)
211 | sf.mmNotificationClient.VTable.OnDeviceRemoved = syscall.NewCallback(sf.noopCallback)
212 | sf.mmNotificationClient.VTable.OnPropertyValueChanged = syscall.NewCallback(sf.noopCallback)
213 |
214 | sf.mmNotificationClient.VTable.OnDefaultDeviceChanged = syscall.NewCallback(sf.defaultDeviceChangedCallback)
215 |
216 | if err := sf.mmDeviceEnumerator.RegisterEndpointNotificationCallback(sf.mmNotificationClient); err != nil {
217 | sf.logger.Warnw("Failed to call RegisterEndpointNotificationCallback", "error", err)
218 | return fmt.Errorf("call RegisterEndpointNotificationCallback: %w", err)
219 | }
220 |
221 | return nil
222 | }
223 |
224 | func (sf *wcaSessionFinder) getMasterSession(mmDevice *wca.IMMDevice, key string, loggerKey string) (*masterSession, error) {
225 |
226 | var audioEndpointVolume *wca.IAudioEndpointVolume
227 |
228 | if err := mmDevice.Activate(wca.IID_IAudioEndpointVolume, wca.CLSCTX_ALL, nil, &audioEndpointVolume); err != nil {
229 | sf.logger.Warnw("Failed to activate AudioEndpointVolume for master session", "error", err)
230 | return nil, fmt.Errorf("activate master session: %w", err)
231 | }
232 |
233 | // create the master session
234 | master, err := newMasterSession(sf.sessionLogger, audioEndpointVolume, sf.eventCtx, key, loggerKey)
235 | if err != nil {
236 | sf.logger.Warnw("Failed to create master session instance", "error", err)
237 | return nil, fmt.Errorf("create master session: %w", err)
238 | }
239 |
240 | return master, nil
241 | }
242 |
243 | func (sf *wcaSessionFinder) enumerateAndAddSessions(sessions *[]Session) error {
244 |
245 | // get list of devices
246 | var deviceCollection *wca.IMMDeviceCollection
247 |
248 | if err := sf.mmDeviceEnumerator.EnumAudioEndpoints(wca.EAll, wca.DEVICE_STATE_ACTIVE, &deviceCollection); err != nil {
249 | sf.logger.Warnw("Failed to enumerate active audio endpoints", "error", err)
250 | return fmt.Errorf("enumerate active audio endpoints: %w", err)
251 | }
252 |
253 | // check how many devices there are
254 | var deviceCount uint32
255 |
256 | if err := deviceCollection.GetCount(&deviceCount); err != nil {
257 | sf.logger.Warnw("Failed to get device count from device collection", "error", err)
258 | return fmt.Errorf("get device count from device collection: %w", err)
259 | }
260 |
261 | // for each device:
262 | for deviceIdx := uint32(0); deviceIdx < deviceCount; deviceIdx++ {
263 |
264 | // get its IMMDevice instance
265 | var endpoint *wca.IMMDevice
266 |
267 | if err := deviceCollection.Item(deviceIdx, &endpoint); err != nil {
268 | sf.logger.Warnw("Failed to get device from device collection",
269 | "deviceIdx", deviceIdx,
270 | "error", err)
271 |
272 | return fmt.Errorf("get device %d from device collection: %w", deviceIdx, err)
273 | }
274 | defer endpoint.Release()
275 |
276 | // get its IMMEndpoint instance to figure out if it's an output device (and we need to enumerate its process sessions later)
277 | dispatch, err := endpoint.QueryInterface(wca.IID_IMMEndpoint)
278 | if err != nil {
279 | sf.logger.Warnw("Failed to query IMMEndpoint for device",
280 | "deviceIdx", deviceIdx,
281 | "error", err)
282 |
283 | return fmt.Errorf("query device %d IMMEndpoint: %w", deviceIdx, err)
284 | }
285 |
286 | // get the device's property store
287 | var propertyStore *wca.IPropertyStore
288 |
289 | if err := endpoint.OpenPropertyStore(wca.STGM_READ, &propertyStore); err != nil {
290 | sf.logger.Warnw("Failed to open property store for endpoint",
291 | "deviceIdx", deviceIdx,
292 | "error", err)
293 |
294 | return fmt.Errorf("open endpoint %d property store: %w", deviceIdx, err)
295 | }
296 | defer propertyStore.Release()
297 |
298 | // query the property store for the device's description and friendly name
299 | value := &wca.PROPVARIANT{}
300 |
301 | if err := propertyStore.GetValue(&wca.PKEY_Device_DeviceDesc, value); err != nil {
302 | sf.logger.Warnw("Failed to get description for device",
303 | "deviceIdx", deviceIdx,
304 | "error", err)
305 |
306 | return fmt.Errorf("get device %d description: %w", deviceIdx, err)
307 | }
308 |
309 | // device description i.e. "Headphones"
310 | endpointDescription := strings.ToLower(value.String())
311 |
312 | if err := propertyStore.GetValue(&wca.PKEY_Device_FriendlyName, value); err != nil {
313 | sf.logger.Warnw("Failed to get friendly name for device",
314 | "deviceIdx", deviceIdx,
315 | "error", err)
316 |
317 | return fmt.Errorf("get device %d friendly name: %w", deviceIdx, err)
318 | }
319 |
320 | // device friendly name i.e. "Headphones (Realtek Audio)"
321 | endpointFriendlyName := value.String()
322 |
323 | // receive a useful object instead of our dispatch
324 | endpointType := (*wca.IMMEndpoint)(unsafe.Pointer(dispatch))
325 | defer endpointType.Release()
326 |
327 | var dataFlow uint32
328 | if err := endpointType.GetDataFlow(&dataFlow); err != nil {
329 | sf.logger.Warnw("Failed to get data flow for endpoint",
330 | "deviceIdx", deviceIdx,
331 | "error", err)
332 |
333 | return fmt.Errorf("get device %d data flow: %w", deviceIdx, err)
334 | }
335 |
336 | sf.logger.Debugw("Enumerated device info",
337 | "deviceIdx", deviceIdx,
338 | "deviceDescription", endpointDescription,
339 | "deviceFriendlyName", endpointFriendlyName,
340 | "dataFlow", dataFlow)
341 |
342 | // if the device is an output device, enumerate and add its per-process audio sessions
343 | if dataFlow == wca.ERender {
344 | if err := sf.enumerateAndAddProcessSessions(endpoint, endpointFriendlyName, sessions); err != nil {
345 | sf.logger.Warnw("Failed to enumerate and add process sessions for device",
346 | "deviceIdx", deviceIdx,
347 | "error", err)
348 |
349 | return fmt.Errorf("enumerate and add device %d process sessions: %w", deviceIdx, err)
350 | }
351 | }
352 |
353 | // for all devices (both input and output), add a named "master" session that can be addressed
354 | // by using the device's friendly name (as appears when the user left-clicks the speaker icon in the tray)
355 | newSession, err := sf.getMasterSession(endpoint,
356 | endpointFriendlyName,
357 | fmt.Sprintf(deviceSessionFormat, endpointDescription))
358 |
359 | if err != nil {
360 | sf.logger.Warnw("Failed to get master session for device",
361 | "deviceIdx", deviceIdx,
362 | "error", err)
363 |
364 | return fmt.Errorf("get device %d master session: %w", deviceIdx, err)
365 | }
366 |
367 | // add it to our slice
368 | *sessions = append(*sessions, newSession)
369 | }
370 |
371 | return nil
372 | }
373 |
374 | func (sf *wcaSessionFinder) enumerateAndAddProcessSessions(
375 | endpoint *wca.IMMDevice,
376 | endpointFriendlyName string,
377 | sessions *[]Session,
378 | ) error {
379 |
380 | sf.logger.Debugw("Enumerating and adding process sessions for audio output device",
381 | "deviceFriendlyName", endpointFriendlyName)
382 |
383 | // query the given IMMDevice's IAudioSessionManager2 interface
384 | var audioSessionManager2 *wca.IAudioSessionManager2
385 |
386 | if err := endpoint.Activate(
387 | wca.IID_IAudioSessionManager2,
388 | wca.CLSCTX_ALL,
389 | nil,
390 | &audioSessionManager2,
391 | ); err != nil {
392 |
393 | sf.logger.Warnw("Failed to activate endpoint as IAudioSessionManager2", "error", err)
394 | return fmt.Errorf("activate endpoint: %w", err)
395 | }
396 | defer audioSessionManager2.Release()
397 |
398 | // get its IAudioSessionEnumerator
399 | var sessionEnumerator *wca.IAudioSessionEnumerator
400 |
401 | if err := audioSessionManager2.GetSessionEnumerator(&sessionEnumerator); err != nil {
402 | return err
403 | }
404 | defer sessionEnumerator.Release()
405 |
406 | // check how many audio sessions there are
407 | var sessionCount int
408 |
409 | if err := sessionEnumerator.GetCount(&sessionCount); err != nil {
410 | sf.logger.Warnw("Failed to get session count from session enumerator", "error", err)
411 | return fmt.Errorf("get session count: %w", err)
412 | }
413 |
414 | sf.logger.Debugw("Got session count from session enumerator", "count", sessionCount)
415 |
416 | // for each session:
417 | for sessionIdx := 0; sessionIdx < sessionCount; sessionIdx++ {
418 |
419 | // get the IAudioSessionControl
420 | var audioSessionControl *wca.IAudioSessionControl
421 | if err := sessionEnumerator.GetSession(sessionIdx, &audioSessionControl); err != nil {
422 | sf.logger.Warnw("Failed to get session from session enumerator",
423 | "error", err,
424 | "sessionIdx", sessionIdx)
425 |
426 | return fmt.Errorf("get session %d from enumerator: %w", sessionIdx, err)
427 | }
428 |
429 | // query its IAudioSessionControl2
430 | dispatch, err := audioSessionControl.QueryInterface(wca.IID_IAudioSessionControl2)
431 | if err != nil {
432 | sf.logger.Warnw("Failed to query session's IAudioSessionControl2",
433 | "error", err,
434 | "sessionIdx", sessionIdx)
435 |
436 | return fmt.Errorf("query session %d IAudioSessionControl2: %w", sessionIdx, err)
437 | }
438 |
439 | // we no longer need the IAudioSessionControl, release it
440 | audioSessionControl.Release()
441 |
442 | // receive a useful object instead of our dispatch
443 | audioSessionControl2 := (*wca.IAudioSessionControl2)(unsafe.Pointer(dispatch))
444 |
445 | var pid uint32
446 |
447 | // get the session's PID
448 | if err := audioSessionControl2.GetProcessId(&pid); err != nil {
449 |
450 | // if this is the system sounds session, GetProcessId will error with an undocumented
451 | // AUDCLNT_S_NO_CURRENT_PROCESS (0x889000D) - this is fine, we actually want to treat it a bit differently
452 | // The first part of this condition will be true if the call to IsSystemSoundsSession fails
453 | // The second part will be true if the original error mesage from GetProcessId doesn't contain this magical
454 | // error code (in decimal format).
455 | isSystemSoundsErr := audioSessionControl2.IsSystemSoundsSession()
456 | if isSystemSoundsErr != nil && !strings.Contains(err.Error(), "143196173") {
457 |
458 | // of course, if it's not the system sounds session, we got a problem
459 | sf.logger.Warnw("Failed to query session's pid",
460 | "error", err,
461 | "isSystemSoundsError", isSystemSoundsErr,
462 | "sessionIdx", sessionIdx)
463 |
464 | return fmt.Errorf("query session %d pid: %w", sessionIdx, err)
465 | }
466 |
467 | // update 2020/08/31: this is also the exact case for UWP applications, so we should no longer override the PID.
468 | // it will successfully update whenever we call GetProcessId for e.g. Video.UI.exe, despite the error being non-nil.
469 | }
470 |
471 | // get its ISimpleAudioVolume
472 | dispatch, err = audioSessionControl2.QueryInterface(wca.IID_ISimpleAudioVolume)
473 | if err != nil {
474 | sf.logger.Warnw("Failed to query session's ISimpleAudioVolume",
475 | "error", err,
476 | "sessionIdx", sessionIdx)
477 |
478 | return fmt.Errorf("query session %d ISimpleAudioVolume: %w", sessionIdx, err)
479 | }
480 |
481 | // make it useful, again
482 | simpleAudioVolume := (*wca.ISimpleAudioVolume)(unsafe.Pointer(dispatch))
483 |
484 | // create the deej session object
485 | newSession, err := newWCASession(sf.sessionLogger, audioSessionControl2, simpleAudioVolume, pid, sf.eventCtx)
486 | if err != nil {
487 |
488 | // this could just mean this process is already closed by now, and the session will be cleaned up later by the OS
489 | if !errors.Is(err, errNoSuchProcess) {
490 | sf.logger.Warnw("Failed to create new WCA session instance",
491 | "error", err,
492 | "sessionIdx", sessionIdx)
493 |
494 | return fmt.Errorf("create wca session for session %d: %w", sessionIdx, err)
495 | }
496 |
497 | // in this case, log it and release the session's handles, then skip to the next one
498 | sf.logger.Debugw("Process already exited, skipping session and releasing handles", "pid", pid)
499 |
500 | audioSessionControl2.Release()
501 | simpleAudioVolume.Release()
502 |
503 | continue
504 | }
505 |
506 | // add it to our slice
507 | *sessions = append(*sessions, newSession)
508 | }
509 |
510 | return nil
511 | }
512 |
513 | func (sf *wcaSessionFinder) defaultDeviceChangedCallback(
514 | this *wca.IMMNotificationClient,
515 | EDataFlow, eRole uint32,
516 | lpcwstr uintptr,
517 | ) (hResult uintptr) {
518 |
519 | // filter out calls that happen in rapid succession
520 | now := time.Now()
521 |
522 | if sf.lastDefaultDeviceChange.Add(minDefaultDeviceChangeThreshold).After(now) {
523 | return
524 | }
525 |
526 | sf.lastDefaultDeviceChange = now
527 |
528 | sf.logger.Debug("Default audio device changed, marking master sessions as stale")
529 | if sf.masterOut != nil {
530 | sf.masterOut.markAsStale()
531 | }
532 |
533 | if sf.masterIn != nil {
534 | sf.masterIn.markAsStale()
535 | }
536 |
537 | return
538 | }
539 | func (sf *wcaSessionFinder) noopCallback() (hResult uintptr) {
540 | return
541 | }
542 |
--------------------------------------------------------------------------------
/pkg/deej/session_linux.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 |
7 | "go.uber.org/zap"
8 |
9 | "github.com/jfreymuth/pulse/proto"
10 | )
11 |
12 | // normal PulseAudio volume (100%)
13 | const maxVolume = 0x10000
14 |
15 | var errNoSuchProcess = errors.New("No such process")
16 |
17 | type paSession struct {
18 | baseSession
19 |
20 | processName string
21 |
22 | client *proto.Client
23 |
24 | sinkInputIndex uint32
25 | sinkInputChannels byte
26 | }
27 |
28 | type masterSession struct {
29 | baseSession
30 |
31 | client *proto.Client
32 |
33 | streamIndex uint32
34 | streamChannels byte
35 | isOutput bool
36 | }
37 |
38 | func newPASession(
39 | logger *zap.SugaredLogger,
40 | client *proto.Client,
41 | sinkInputIndex uint32,
42 | sinkInputChannels byte,
43 | processName string,
44 | ) *paSession {
45 |
46 | s := &paSession{
47 | client: client,
48 | sinkInputIndex: sinkInputIndex,
49 | sinkInputChannels: sinkInputChannels,
50 | }
51 |
52 | s.processName = processName
53 | s.name = processName
54 | s.humanReadableDesc = processName
55 |
56 | // use a self-identifying session name e.g. deej.sessions.chrome
57 | s.logger = logger.Named(s.Key())
58 | s.logger.Debugw(sessionCreationLogMessage, "session", s)
59 |
60 | return s
61 | }
62 |
63 | func newMasterSession(
64 | logger *zap.SugaredLogger,
65 | client *proto.Client,
66 | streamIndex uint32,
67 | streamChannels byte,
68 | isOutput bool,
69 | ) *masterSession {
70 |
71 | s := &masterSession{
72 | client: client,
73 | streamIndex: streamIndex,
74 | streamChannels: streamChannels,
75 | isOutput: isOutput,
76 | }
77 |
78 | var key string
79 |
80 | if isOutput {
81 | key = masterSessionName
82 | } else {
83 | key = inputSessionName
84 | }
85 |
86 | s.logger = logger.Named(key)
87 | s.master = true
88 | s.name = key
89 | s.humanReadableDesc = key
90 |
91 | s.logger.Debugw(sessionCreationLogMessage, "session", s)
92 |
93 | return s
94 | }
95 |
96 | func (s *paSession) GetVolume() float32 {
97 | request := proto.GetSinkInputInfo{
98 | SinkInputIndex: s.sinkInputIndex,
99 | }
100 | reply := proto.GetSinkInputInfoReply{}
101 |
102 | if err := s.client.Request(&request, &reply); err != nil {
103 | s.logger.Warnw("Failed to get session volume", "error", err)
104 | }
105 |
106 | level := parseChannelVolumes(reply.ChannelVolumes)
107 |
108 | return level
109 | }
110 |
111 | func (s *paSession) SetVolume(v float32) error {
112 | volumes := createChannelVolumes(s.sinkInputChannels, v)
113 | request := proto.SetSinkInputVolume{
114 | SinkInputIndex: s.sinkInputIndex,
115 | ChannelVolumes: volumes,
116 | }
117 |
118 | if err := s.client.Request(&request, nil); err != nil {
119 | s.logger.Warnw("Failed to set session volume", "error", err)
120 | return fmt.Errorf("adjust session volume: %w", err)
121 | }
122 |
123 | s.logger.Debugw("Adjusting session volume", "to", fmt.Sprintf("%.2f", v))
124 |
125 | return nil
126 | }
127 |
128 | func (s *paSession) Release() {
129 | s.logger.Debug("Releasing audio session")
130 | }
131 |
132 | func (s *paSession) String() string {
133 | return fmt.Sprintf(sessionStringFormat, s.humanReadableDesc, s.GetVolume())
134 | }
135 |
136 | func (s *masterSession) GetVolume() float32 {
137 | var level float32
138 |
139 | if s.isOutput {
140 | request := proto.GetSinkInfo{
141 | SinkIndex: s.streamIndex,
142 | }
143 | reply := proto.GetSinkInfoReply{}
144 |
145 | if err := s.client.Request(&request, &reply); err != nil {
146 | s.logger.Warnw("Failed to get session volume", "error", err)
147 | return 0
148 | }
149 |
150 | level = parseChannelVolumes(reply.ChannelVolumes)
151 | } else {
152 | request := proto.GetSourceInfo{
153 | SourceIndex: s.streamIndex,
154 | }
155 | reply := proto.GetSourceInfoReply{}
156 |
157 | if err := s.client.Request(&request, &reply); err != nil {
158 | s.logger.Warnw("Failed to get session volume", "error", err)
159 | return 0
160 | }
161 |
162 | level = parseChannelVolumes(reply.ChannelVolumes)
163 | }
164 |
165 | return level
166 | }
167 |
168 | func (s *masterSession) SetVolume(v float32) error {
169 | var request proto.RequestArgs
170 |
171 | volumes := createChannelVolumes(s.streamChannels, v)
172 |
173 | if s.isOutput {
174 | request = &proto.SetSinkVolume{
175 | SinkIndex: s.streamIndex,
176 | ChannelVolumes: volumes,
177 | }
178 | } else {
179 | request = &proto.SetSourceVolume{
180 | SourceIndex: s.streamIndex,
181 | ChannelVolumes: volumes,
182 | }
183 | }
184 |
185 | if err := s.client.Request(request, nil); err != nil {
186 | s.logger.Warnw("Failed to set session volume",
187 | "error", err,
188 | "volume", v)
189 |
190 | return fmt.Errorf("adjust session volume: %w", err)
191 | }
192 |
193 | s.logger.Debugw("Adjusting session volume", "to", fmt.Sprintf("%.2f", v))
194 |
195 | return nil
196 | }
197 |
198 | func (s *masterSession) Release() {
199 | s.logger.Debug("Releasing audio session")
200 | }
201 |
202 | func (s *masterSession) String() string {
203 | return fmt.Sprintf(sessionStringFormat, s.humanReadableDesc, s.GetVolume())
204 | }
205 |
206 | func createChannelVolumes(channels byte, volume float32) []uint32 {
207 | volumes := make([]uint32, channels)
208 |
209 | for i := range volumes {
210 | volumes[i] = uint32(volume * maxVolume)
211 | }
212 |
213 | return volumes
214 | }
215 |
216 | func parseChannelVolumes(volumes []uint32) float32 {
217 | var level uint32
218 |
219 | for _, volume := range volumes {
220 | level += volume
221 | }
222 |
223 | return float32(level) / float32(len(volumes)) / float32(maxVolume)
224 | }
225 |
--------------------------------------------------------------------------------
/pkg/deej/session_map.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | "strings"
7 | "sync"
8 | "time"
9 |
10 | "github.com/omriharel/deej/pkg/deej/util"
11 | "github.com/thoas/go-funk"
12 | "go.uber.org/zap"
13 | )
14 |
15 | type sessionMap struct {
16 | deej *Deej
17 | logger *zap.SugaredLogger
18 |
19 | m map[string][]Session
20 | lock sync.Locker
21 |
22 | sessionFinder SessionFinder
23 |
24 | lastSessionRefresh time.Time
25 | unmappedSessions []Session
26 | }
27 |
28 | const (
29 | masterSessionName = "master" // master device volume
30 | systemSessionName = "system" // system sounds volume
31 | inputSessionName = "mic" // microphone input level
32 |
33 | // some targets need to be transformed before their correct audio sessions can be accessed.
34 | // this prefix identifies those targets to ensure they don't contradict with another similarly-named process
35 | specialTargetTransformPrefix = "deej."
36 |
37 | // targets the currently active window (Windows-only, experimental)
38 | specialTargetCurrentWindow = "current"
39 |
40 | // targets all currently unmapped sessions (experimental)
41 | specialTargetAllUnmapped = "unmapped"
42 |
43 | // this threshold constant assumes that re-acquiring all sessions is a kind of expensive operation,
44 | // and needs to be limited in some manner. this value was previously user-configurable through a config
45 | // key "process_refresh_frequency", but exposing this type of implementation detail seems wrong now
46 | minTimeBetweenSessionRefreshes = time.Second * 5
47 |
48 | // determines whether the map should be refreshed when a slider moves.
49 | // this is a bit greedy but allows us to ensure sessions are always re-acquired, which is
50 | // especially important for process groups (because you can have one ongoing session
51 | // always preventing lookup of other processes bound to its slider, which forces the user
52 | // to manually refresh sessions). a cleaner way to do this down the line is by registering to notifications
53 | // whenever a new session is added, but that's too hard to justify for how easy this solution is
54 | maxTimeBetweenSessionRefreshes = time.Second * 45
55 | )
56 |
57 | // this matches friendly device names (on Windows), e.g. "Headphones (Realtek Audio)"
58 | var deviceSessionKeyPattern = regexp.MustCompile(`^.+ \(.+\)
max tokens
The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by
removing the max tokens filter.
)
59 |
60 | func newSessionMap(deej *Deej, logger *zap.SugaredLogger, sessionFinder SessionFinder) (*sessionMap, error) {
61 | logger = logger.Named("sessions")
62 |
63 | m := &sessionMap{
64 | deej: deej,
65 | logger: logger,
66 | m: make(map[string][]Session),
67 | lock: &sync.Mutex{},
68 | sessionFinder: sessionFinder,
69 | }
70 |
71 | logger.Debug("Created session map instance")
72 |
73 | return m, nil
74 | }
75 |
76 | func (m *sessionMap) initialize() error {
77 | if err := m.getAndAddSessions(); err != nil {
78 | m.logger.Warnw("Failed to get all sessions during session map initialization", "error", err)
79 | return fmt.Errorf("get all sessions during init: %w", err)
80 | }
81 |
82 | m.setupOnConfigReload()
83 | m.setupOnSliderMove()
84 |
85 | return nil
86 | }
87 |
88 | func (m *sessionMap) release() error {
89 | if err := m.sessionFinder.Release(); err != nil {
90 | m.logger.Warnw("Failed to release session finder during session map release", "error", err)
91 | return fmt.Errorf("release session finder during release: %w", err)
92 | }
93 |
94 | return nil
95 | }
96 |
97 | // assumes the session map is clean!
98 | // only call on a new session map or as part of refreshSessions which calls reset
99 | func (m *sessionMap) getAndAddSessions() error {
100 |
101 | // mark that we're refreshing before anything else
102 | m.lastSessionRefresh = time.Now()
103 | m.unmappedSessions = nil
104 |
105 | sessions, err := m.sessionFinder.GetAllSessions()
106 | if err != nil {
107 | m.logger.Warnw("Failed to get sessions from session finder", "error", err)
108 | return fmt.Errorf("get sessions from SessionFinder: %w", err)
109 | }
110 |
111 | for _, session := range sessions {
112 | m.add(session)
113 |
114 | if !m.sessionMapped(session) {
115 | m.logger.Debugw("Tracking unmapped session", "session", session)
116 | m.unmappedSessions = append(m.unmappedSessions, session)
117 | }
118 | }
119 |
120 | m.logger.Infow("Got all audio sessions successfully", "sessionMap", m)
121 |
122 | return nil
123 | }
124 |
125 | func (m *sessionMap) setupOnConfigReload() {
126 | configReloadedChannel := m.deej.config.SubscribeToChanges()
127 |
128 | go func() {
129 | for {
130 | select {
131 | case <-configReloadedChannel:
132 | m.logger.Info("Detected config reload, attempting to re-acquire all audio sessions")
133 | m.refreshSessions(false)
134 | }
135 | }
136 | }()
137 | }
138 |
139 | func (m *sessionMap) setupOnSliderMove() {
140 | sliderEventsChannel := m.deej.serial.SubscribeToSliderMoveEvents()
141 |
142 | go func() {
143 | for {
144 | select {
145 | case event := <-sliderEventsChannel:
146 | m.handleSliderMoveEvent(event)
147 | }
148 | }
149 | }()
150 | }
151 |
152 | // performance: explain why force == true at every such use to avoid unintended forced refresh spams
153 | func (m *sessionMap) refreshSessions(force bool) {
154 |
155 | // make sure enough time passed since the last refresh, unless force is true in which case always clear
156 | if !force && m.lastSessionRefresh.Add(minTimeBetweenSessionRefreshes).After(time.Now()) {
157 | return
158 | }
159 |
160 | // clear and release sessions first
161 | m.clear()
162 |
163 | if err := m.getAndAddSessions(); err != nil {
164 | m.logger.Warnw("Failed to re-acquire all audio sessions", "error", err)
165 | } else {
166 | m.logger.Debug("Re-acquired sessions successfully")
167 | }
168 | }
169 |
170 | // returns true if a session is not currently mapped to any slider, false otherwise
171 | // special sessions (master, system, mic) and device-specific sessions always count as mapped,
172 | // even when absent from the config. this makes sense for every current feature that uses "unmapped sessions"
173 | func (m *sessionMap) sessionMapped(session Session) bool {
174 |
175 | // count master/system/mic as mapped
176 | if funk.ContainsString([]string{masterSessionName, systemSessionName, inputSessionName}, session.Key()) {
177 | return true
178 | }
179 |
180 | // count device sessions as mapped
181 | if deviceSessionKeyPattern.MatchString(session.Key()) {
182 | return true
183 | }
184 |
185 | matchFound := false
186 |
187 | // look through the actual mappings
188 | m.deej.config.SliderMapping.iterate(func(sliderIdx int, targets []string) {
189 | for _, target := range targets {
190 |
191 | // ignore special transforms
192 | if m.targetHasSpecialTransform(target) {
193 | continue
194 | }
195 |
196 | // safe to assume this has a single element because we made sure there's no special transform
197 | target = m.resolveTarget(target)[0]
198 |
199 | if target == session.Key() {
200 | matchFound = true
201 | return
202 | }
203 | }
204 | })
205 |
206 | return matchFound
207 | }
208 |
209 | func (m *sessionMap) handleSliderMoveEvent(event SliderMoveEvent) {
210 |
211 | // first of all, ensure our session map isn't moldy
212 | if m.lastSessionRefresh.Add(maxTimeBetweenSessionRefreshes).Before(time.Now()) {
213 | m.logger.Debug("Stale session map detected on slider move, refreshing")
214 | m.refreshSessions(true)
215 | }
216 |
217 | // get the targets mapped to this slider from the config
218 | targets, ok := m.deej.config.SliderMapping.get(event.SliderID)
219 |
220 | // if slider not found in config, silently ignore
221 | if !ok {
222 | return
223 | }
224 |
225 | targetFound := false
226 | adjustmentFailed := false
227 |
228 | // for each possible target for this slider...
229 | for _, target := range targets {
230 |
231 | // resolve the target name by cleaning it up and applying any special transformations.
232 | // depending on the transformation applied, this can result in more than one target name
233 | resolvedTargets := m.resolveTarget(target)
234 |
235 | // for each resolved target...
236 | for _, resolvedTarget := range resolvedTargets {
237 |
238 | // check the map for matching sessions
239 | sessions, ok := m.get(resolvedTarget)
240 |
241 | // no sessions matching this target - move on
242 | if !ok {
243 | continue
244 | }
245 |
246 | targetFound = true
247 |
248 | // iterate all matching sessions and adjust the volume of each one
249 | for _, session := range sessions {
250 | if session.GetVolume() != event.PercentValue {
251 | if err := session.SetVolume(event.PercentValue); err != nil {
252 | m.logger.Warnw("Failed to set target session volume", "error", err)
253 | adjustmentFailed = true
254 | }
255 | }
256 | }
257 | }
258 | }
259 |
260 | // if we still haven't found a target or the volume adjustment failed, maybe look for the target again.
261 | // processes could've opened since the last time this slider moved.
262 | // if they haven't, the cooldown will take care to not spam it up
263 | if !targetFound {
264 | m.refreshSessions(false)
265 | } else if adjustmentFailed {
266 |
267 | // performance: the reason that forcing a refresh here is okay is that we'll only get here
268 | // when a session's SetVolume call errored, such as in the case of a stale master session
269 | // (or another, more catastrophic failure happens)
270 | m.refreshSessions(true)
271 | }
272 | }
273 |
274 | func (m *sessionMap) targetHasSpecialTransform(target string) bool {
275 | return strings.HasPrefix(target, specialTargetTransformPrefix)
276 | }
277 |
278 | func (m *sessionMap) resolveTarget(target string) []string {
279 |
280 | // start by ignoring the case
281 | target = strings.ToLower(target)
282 |
283 | // look for any special targets first, by examining the prefix
284 | if m.targetHasSpecialTransform(target) {
285 | return m.applyTargetTransform(strings.TrimPrefix(target, specialTargetTransformPrefix))
286 | }
287 |
288 | return []string{target}
289 | }
290 |
291 | func (m *sessionMap) applyTargetTransform(specialTargetName string) []string {
292 |
293 | // select the transformation based on its name
294 | switch specialTargetName {
295 |
296 | // get current active window
297 | case specialTargetCurrentWindow:
298 | currentWindowProcessNames, err := util.GetCurrentWindowProcessNames()
299 |
300 | // silently ignore errors here, as this is on deej's "hot path" (and it could just mean the user's running linux)
301 | if err != nil {
302 | return nil
303 | }
304 |
305 | // we could have gotten a non-lowercase names from that, so let's ensure we return ones that are lowercase
306 | for targetIdx, target := range currentWindowProcessNames {
307 | currentWindowProcessNames[targetIdx] = strings.ToLower(target)
308 | }
309 |
310 | // remove dupes
311 | return funk.UniqString(currentWindowProcessNames)
312 |
313 | // get currently unmapped sessions
314 | case specialTargetAllUnmapped:
315 | targetKeys := make([]string, len(m.unmappedSessions))
316 | for sessionIdx, session := range m.unmappedSessions {
317 | targetKeys[sessionIdx] = session.Key()
318 | }
319 |
320 | return targetKeys
321 | }
322 |
323 | return nil
324 | }
325 |
326 | func (m *sessionMap) add(value Session) {
327 | m.lock.Lock()
328 | defer m.lock.Unlock()
329 |
330 | key := value.Key()
331 |
332 | existing, ok := m.m[key]
333 | if !ok {
334 | m.m[key] = []Session{value}
335 | } else {
336 | m.m[key] = append(existing, value)
337 | }
338 | }
339 |
340 | func (m *sessionMap) get(key string) ([]Session, bool) {
341 | m.lock.Lock()
342 | defer m.lock.Unlock()
343 |
344 | value, ok := m.m[key]
345 | return value, ok
346 | }
347 |
348 | func (m *sessionMap) clear() {
349 | m.lock.Lock()
350 | defer m.lock.Unlock()
351 |
352 | m.logger.Debug("Releasing and clearing all audio sessions")
353 |
354 | for key, sessions := range m.m {
355 | for _, session := range sessions {
356 | session.Release()
357 | }
358 |
359 | delete(m.m, key)
360 | }
361 |
362 | m.logger.Debug("Session map cleared")
363 | }
364 |
365 | func (m *sessionMap) String() string {
366 | m.lock.Lock()
367 | defer m.lock.Unlock()
368 |
369 | sessionCount := 0
370 |
371 | for _, value := range m.m {
372 | sessionCount += len(value)
373 | }
374 |
375 | return fmt.Sprintf("<%d audio sessions>", sessionCount)
376 | }
377 |
--------------------------------------------------------------------------------
/pkg/deej/session_windows.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 |
8 | ole "github.com/go-ole/go-ole"
9 | ps "github.com/mitchellh/go-ps"
10 | wca "github.com/moutend/go-wca"
11 | "go.uber.org/zap"
12 | )
13 |
14 | var errNoSuchProcess = errors.New("No such process")
15 | var errRefreshSessions = errors.New("Trigger session refresh")
16 |
17 | type wcaSession struct {
18 | baseSession
19 |
20 | pid uint32
21 | processName string
22 |
23 | control *wca.IAudioSessionControl2
24 | volume *wca.ISimpleAudioVolume
25 |
26 | eventCtx *ole.GUID
27 | }
28 |
29 | type masterSession struct {
30 | baseSession
31 |
32 | volume *wca.IAudioEndpointVolume
33 |
34 | eventCtx *ole.GUID
35 |
36 | stale bool // when set to true, we should refresh sessions on the next call to SetVolume
37 | }
38 |
39 | func newWCASession(
40 | logger *zap.SugaredLogger,
41 | control *wca.IAudioSessionControl2,
42 | volume *wca.ISimpleAudioVolume,
43 | pid uint32,
44 | eventCtx *ole.GUID,
45 | ) (*wcaSession, error) {
46 |
47 | s := &wcaSession{
48 | control: control,
49 | volume: volume,
50 | pid: pid,
51 | eventCtx: eventCtx,
52 | }
53 |
54 | // special treatment for system sounds session
55 | if pid == 0 {
56 | s.system = true
57 | s.name = systemSessionName
58 | s.humanReadableDesc = "system sounds"
59 | } else {
60 |
61 | // find our session's process name
62 | process, err := ps.FindProcess(int(pid))
63 | if err != nil {
64 | logger.Warnw("Failed to find process name by ID", "pid", pid, "error", err)
65 | defer s.Release()
66 |
67 | return nil, fmt.Errorf("find process name by pid: %w", err)
68 | }
69 |
70 | // this PID may be invalid - this means the process has already been
71 | // closed and we shouldn't create a session for it.
72 | if process == nil {
73 | logger.Debugw("Process already exited, not creating audio session", "pid", pid)
74 | return nil, errNoSuchProcess
75 | }
76 |
77 | s.processName = process.Executable()
78 | s.name = s.processName
79 | s.humanReadableDesc = fmt.Sprintf("%s (pid %d)", s.processName, s.pid)
80 | }
81 |
82 | // use a self-identifying session name e.g. deej.sessions.chrome
83 | s.logger = logger.Named(strings.TrimSuffix(s.Key(), ".exe"))
84 | s.logger.Debugw(sessionCreationLogMessage, "session", s)
85 |
86 | return s, nil
87 | }
88 |
89 | func newMasterSession(
90 | logger *zap.SugaredLogger,
91 | volume *wca.IAudioEndpointVolume,
92 | eventCtx *ole.GUID,
93 | key string,
94 | loggerKey string,
95 | ) (*masterSession, error) {
96 |
97 | s := &masterSession{
98 | volume: volume,
99 | eventCtx: eventCtx,
100 | }
101 |
102 | s.logger = logger.Named(loggerKey)
103 | s.master = true
104 | s.name = key
105 | s.humanReadableDesc = key
106 |
107 | s.logger.Debugw(sessionCreationLogMessage, "session", s)
108 |
109 | return s, nil
110 | }
111 |
112 | func (s *wcaSession) GetVolume() float32 {
113 | var level float32
114 |
115 | if err := s.volume.GetMasterVolume(&level); err != nil {
116 | s.logger.Warnw("Failed to get session volume", "error", err)
117 | }
118 |
119 | return level
120 | }
121 |
122 | func (s *wcaSession) SetVolume(v float32) error {
123 | if err := s.volume.SetMasterVolume(v, s.eventCtx); err != nil {
124 | s.logger.Warnw("Failed to set session volume", "error", err)
125 | return fmt.Errorf("adjust session volume: %w", err)
126 | }
127 |
128 | // mitigate expired sessions by checking the state whenever we change volumes
129 | var state uint32
130 |
131 | if err := s.control.GetState(&state); err != nil {
132 | s.logger.Warnw("Failed to get session state while setting volume", "error", err)
133 | return fmt.Errorf("get session state: %w", err)
134 | }
135 |
136 | if state == wca.AudioSessionStateExpired {
137 | s.logger.Warnw("Audio session expired, triggering session refresh")
138 | return errRefreshSessions
139 | }
140 |
141 | s.logger.Debugw("Adjusting session volume", "to", fmt.Sprintf("%.2f", v))
142 |
143 | return nil
144 | }
145 |
146 | func (s *wcaSession) Release() {
147 | s.logger.Debug("Releasing audio session")
148 |
149 | s.volume.Release()
150 | s.control.Release()
151 | }
152 |
153 | func (s *wcaSession) String() string {
154 | return fmt.Sprintf(sessionStringFormat, s.humanReadableDesc, s.GetVolume())
155 | }
156 |
157 | func (s *masterSession) GetVolume() float32 {
158 | var level float32
159 |
160 | if err := s.volume.GetMasterVolumeLevelScalar(&level); err != nil {
161 | s.logger.Warnw("Failed to get session volume", "error", err)
162 | }
163 |
164 | return level
165 | }
166 |
167 | func (s *masterSession) SetVolume(v float32) error {
168 | if s.stale {
169 | s.logger.Warnw("Session expired because default device has changed, triggering session refresh")
170 | return errRefreshSessions
171 | }
172 |
173 | if err := s.volume.SetMasterVolumeLevelScalar(v, s.eventCtx); err != nil {
174 | s.logger.Warnw("Failed to set session volume",
175 | "error", err,
176 | "volume", v)
177 |
178 | return fmt.Errorf("adjust session volume: %w", err)
179 | }
180 |
181 | s.logger.Debugw("Adjusting session volume", "to", fmt.Sprintf("%.2f", v))
182 |
183 | return nil
184 | }
185 |
186 | func (s *masterSession) Release() {
187 | s.logger.Debug("Releasing audio session")
188 |
189 | s.volume.Release()
190 | }
191 |
192 | func (s *masterSession) String() string {
193 | return fmt.Sprintf(sessionStringFormat, s.humanReadableDesc, s.GetVolume())
194 | }
195 |
196 | func (s *masterSession) markAsStale() {
197 | s.stale = true
198 | }
199 |
--------------------------------------------------------------------------------
/pkg/deej/slider_map.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 | "sync"
7 |
8 | "github.com/thoas/go-funk"
9 | )
10 |
11 | type sliderMap struct {
12 | m map[int][]string
13 | lock sync.Locker
14 | }
15 |
16 | func newSliderMap() *sliderMap {
17 | return &sliderMap{
18 | m: make(map[int][]string),
19 | lock: &sync.Mutex{},
20 | }
21 | }
22 |
23 | func sliderMapFromConfigs(userMapping map[string][]string, internalMapping map[string][]string) *sliderMap {
24 | resultMap := newSliderMap()
25 |
26 | // copy targets from user config, ignoring empty values
27 | for sliderIdxString, targets := range userMapping {
28 | sliderIdx, _ := strconv.Atoi(sliderIdxString)
29 |
30 | resultMap.set(sliderIdx, funk.FilterString(targets, func(s string) bool {
31 | return s != ""
32 | }))
33 | }
34 |
35 | // add targets from internal configs, ignoring duplicate or empty values
36 | for sliderIdxString, targets := range internalMapping {
37 | sliderIdx, _ := strconv.Atoi(sliderIdxString)
38 |
39 | existingTargets, ok := resultMap.get(sliderIdx)
40 | if !ok {
41 | existingTargets = []string{}
42 | }
43 |
44 | filteredTargets := funk.FilterString(targets, func(s string) bool {
45 | return (!funk.ContainsString(existingTargets, s)) && s != ""
46 | })
47 |
48 | existingTargets = append(existingTargets, filteredTargets...)
49 | resultMap.set(sliderIdx, existingTargets)
50 | }
51 |
52 | return resultMap
53 | }
54 |
55 | func (m *sliderMap) iterate(f func(int, []string)) {
56 | m.lock.Lock()
57 | defer m.lock.Unlock()
58 |
59 | for key, value := range m.m {
60 | f(key, value)
61 | }
62 | }
63 |
64 | func (m *sliderMap) get(key int) ([]string, bool) {
65 | m.lock.Lock()
66 | defer m.lock.Unlock()
67 |
68 | value, ok := m.m[key]
69 | return value, ok
70 | }
71 |
72 | func (m *sliderMap) set(key int, value []string) {
73 | m.lock.Lock()
74 | defer m.lock.Unlock()
75 |
76 | m.m[key] = value
77 | }
78 |
79 | func (m *sliderMap) String() string {
80 | m.lock.Lock()
81 | defer m.lock.Unlock()
82 |
83 | sliderCount := 0
84 | targetCount := 0
85 |
86 | for _, value := range m.m {
87 | sliderCount++
88 | targetCount += len(value)
89 | }
90 |
91 | return fmt.Sprintf("<%d sliders mapped to %d targets>", sliderCount, targetCount)
92 | }
93 |
--------------------------------------------------------------------------------
/pkg/deej/tray.go:
--------------------------------------------------------------------------------
1 | package deej
2 |
3 | import (
4 | "github.com/getlantern/systray"
5 |
6 | "github.com/omriharel/deej/pkg/deej/icon"
7 | "github.com/omriharel/deej/pkg/deej/util"
8 | )
9 |
10 | func (d *Deej) initializeTray(onDone func()) {
11 | logger := d.logger.Named("tray")
12 |
13 | onReady := func() {
14 | logger.Debug("Tray instance ready")
15 |
16 | systray.SetTemplateIcon(icon.DeejLogo, icon.DeejLogo)
17 | systray.SetTitle("deej")
18 | systray.SetTooltip("deej")
19 |
20 | editConfig := systray.AddMenuItem("Edit configuration", "Open config file with notepad")
21 | editConfig.SetIcon(icon.EditConfig)
22 |
23 | refreshSessions := systray.AddMenuItem("Re-scan audio sessions", "Manually refresh audio sessions if something's stuck")
24 | refreshSessions.SetIcon(icon.RefreshSessions)
25 |
26 | if d.version != "" {
27 | systray.AddSeparator()
28 | versionInfo := systray.AddMenuItem(d.version, "")
29 | versionInfo.Disable()
30 | }
31 |
32 | systray.AddSeparator()
33 | quit := systray.AddMenuItem("Quit", "Stop deej and quit")
34 |
35 | // wait on things to happen
36 | go func() {
37 | for {
38 | select {
39 |
40 | // quit
41 | case <-quit.ClickedCh:
42 | logger.Info("Quit menu item clicked, stopping")
43 |
44 | d.signalStop()
45 |
46 | // edit config
47 | case <-editConfig.ClickedCh:
48 | logger.Info("Edit config menu item clicked, opening config for editing")
49 |
50 | editor := "notepad.exe"
51 | if util.Linux() {
52 | editor = "gedit"
53 | }
54 |
55 | if err := util.OpenExternal(logger, editor, userConfigFilepath); err != nil {
56 | logger.Warnw("Failed to open config file for editing", "error", err)
57 | }
58 |
59 | // refresh sessions
60 | case <-refreshSessions.ClickedCh:
61 | logger.Info("Refresh sessions menu item clicked, triggering session map refresh")
62 |
63 | // performance: the reason that forcing a refresh here is okay is that users can't spam the
64 | // right-click -> select-this-option sequence at a rate that's meaningful to performance
65 | d.sessions.refreshSessions(true)
66 | }
67 | }
68 | }()
69 |
70 | // actually start the main runtime
71 | onDone()
72 | }
73 |
74 | onExit := func() {
75 | logger.Debug("Tray exited")
76 | }
77 |
78 | // start the tray icon
79 | logger.Debug("Running in tray")
80 | systray.Run(onReady, onExit)
81 | }
82 |
83 | func (d *Deej) stopTray() {
84 | d.logger.Debug("Quitting tray")
85 | systray.Quit()
86 | }
87 |
--------------------------------------------------------------------------------
/pkg/deej/util/util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "os"
7 | "os/exec"
8 | "os/signal"
9 | "runtime"
10 | "syscall"
11 |
12 | "go.uber.org/zap"
13 | )
14 |
15 | // EnsureDirExists creates the given directory path if it doesn't already exist
16 | func EnsureDirExists(path string) error {
17 | if err := os.MkdirAll(path, os.ModePerm); err != nil {
18 | return fmt.Errorf("ensure directory exists (%s): %w", path, err)
19 | }
20 |
21 | return nil
22 | }
23 |
24 | // FileExists checks if a file exists and is not a directory before we
25 | // try using it to prevent further errors.
26 | func FileExists(filename string) bool {
27 | info, err := os.Stat(filename)
28 | if os.IsNotExist(err) {
29 | return false
30 | }
31 | return !info.IsDir()
32 | }
33 |
34 | // Linux returns true if we're running on Linux
35 | func Linux() bool {
36 | return runtime.GOOS == "linux"
37 | }
38 |
39 | // SetupCloseHandler creates a 'listener' on a new goroutine which will notify the
40 | // program if it receives an interrupt from the OS
41 | func SetupCloseHandler() chan os.Signal {
42 | c := make(chan os.Signal)
43 | signal.Notify(c, os.Interrupt, syscall.SIGTERM)
44 |
45 | return c
46 | }
47 |
48 | // GetCurrentWindowProcessNames returns the process names (including extension, if applicable)
49 | // of the current foreground window. This includes child processes belonging to the window.
50 | // This is currently only implemented for Windows
51 | func GetCurrentWindowProcessNames() ([]string, error) {
52 | return getCurrentWindowProcessNames()
53 | }
54 |
55 | // OpenExternal spawns a detached window with the provided command and argument
56 | func OpenExternal(logger *zap.SugaredLogger, cmd string, arg string) error {
57 |
58 | // use cmd for windows, bash for linux
59 | execCommandArgs := []string{"cmd.exe", "/C", "start", "/b", cmd, arg}
60 | if Linux() {
61 | execCommandArgs = []string{"/bin/bash", "-c", fmt.Sprintf("%s %s", cmd, arg)}
62 | }
63 |
64 | command := exec.Command(execCommandArgs[0], execCommandArgs[1:]...)
65 |
66 | if err := command.Run(); err != nil {
67 | logger.Warnw("Failed to spawn detached process",
68 | "command", cmd,
69 | "argument", arg,
70 | "error", err)
71 |
72 | return fmt.Errorf("spawn detached proc: %w", err)
73 | }
74 |
75 | return nil
76 | }
77 |
78 | // NormalizeScalar "trims" the given float32 to 2 points of precision (e.g. 0.15442 -> 0.15)
79 | // This is used both for windows core audio volume levels and for cleaning up slider level values from serial
80 | func NormalizeScalar(v float32) float32 {
81 | return float32(math.Floor(float64(v)*100) / 100.0)
82 | }
83 |
84 | // SignificantlyDifferent returns true if there's a significant enough volume difference between two given values
85 | func SignificantlyDifferent(old float32, new float32, noiseReductionLevel string) bool {
86 |
87 | const (
88 | noiseReductionHigh = "high"
89 | noiseReductionLow = "low"
90 | )
91 |
92 | // this threshold is solely responsible for dealing with hardware interference when
93 | // sliders are producing noisy values. this value should be a median value between two
94 | // round percent values. for instance, 0.025 means volume can move at 3% increments
95 | var significantDifferenceThreshold float64
96 |
97 | // choose our noise reduction level based on the config-provided value
98 | switch noiseReductionLevel {
99 | case noiseReductionHigh:
100 | significantDifferenceThreshold = 0.035
101 | break
102 | case noiseReductionLow:
103 | significantDifferenceThreshold = 0.015
104 | break
105 | default:
106 | significantDifferenceThreshold = 0.025
107 | break
108 | }
109 |
110 | if math.Abs(float64(old-new)) >= significantDifferenceThreshold {
111 | return true
112 | }
113 |
114 | // special behavior is needed around the edges of 0.0 and 1.0 - this makes it snap (just a tiny bit) to them
115 | if (almostEquals(new, 1.0) && old != 1.0) || (almostEquals(new, 0.0) && old != 0.0) {
116 | return true
117 | }
118 |
119 | // values are close enough to not warrant any action
120 | return false
121 | }
122 |
123 | // a helper to make sure volume snaps correctly to 0 and 100, where appropriate
124 | func almostEquals(a float32, b float32) bool {
125 | return math.Abs(float64(a-b)) < 0.000001
126 | }
127 |
--------------------------------------------------------------------------------
/pkg/deej/util/util_linux.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "errors"
5 | )
6 |
7 | func getCurrentWindowProcessNames() ([]string, error) {
8 | return nil, errors.New("Not implemented")
9 | }
10 |
--------------------------------------------------------------------------------
/pkg/deej/util/util_windows.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "fmt"
5 | "syscall"
6 | "time"
7 | "unsafe"
8 |
9 | "github.com/lxn/win"
10 | "github.com/mitchellh/go-ps"
11 | )
12 |
13 | const (
14 | getCurrentWindowInternalCooldown = time.Millisecond * 350
15 | )
16 |
17 | var (
18 | lastGetCurrentWindowResult []string
19 | lastGetCurrentWindowCall = time.Now()
20 | )
21 |
22 | func getCurrentWindowProcessNames() ([]string, error) {
23 |
24 | // apply an internal cooldown on this function to avoid calling windows API functions too frequently.
25 | // return a cached value during that cooldown
26 | now := time.Now()
27 | if lastGetCurrentWindowCall.Add(getCurrentWindowInternalCooldown).After(now) {
28 | return lastGetCurrentWindowResult, nil
29 | }
30 |
31 | lastGetCurrentWindowCall = now
32 |
33 | // the logic of this implementation is a bit convoluted because of the way UWP apps
34 | // (also known as "modern win 10 apps" or "microsoft store apps") work.
35 | // these are rendered in a parent container by the name of ApplicationFrameHost.exe.
36 | // when windows's GetForegroundWindow is called, it returns the window owned by that parent process.
37 | // so whenever we get that, we need to go and look through its child windows until we find one with a different PID.
38 | // this behavior is most common with UWP, but it actually applies to any "container" process:
39 | // an acceptable approach is to return a slice of possible process names that could be the "right" one, looking
40 | // them up is fairly cheap and covers the most bases for apps that hide their audio-playing inside another process
41 | // (like steam, and the league client, and any UWP app)
42 |
43 | result := []string{}
44 |
45 | // a callback that will be called for each child window of the foreground window, if it has any
46 | enumChildWindowsCallback := func(childHWND *uintptr, lParam *uintptr) uintptr {
47 |
48 | // cast the outer lp into something we can work with (maybe closures are good enough?)
49 | ownerPID := (*uint32)(unsafe.Pointer(lParam))
50 |
51 | // get the child window's real PID
52 | var childPID uint32
53 | win.GetWindowThreadProcessId((win.HWND)(unsafe.Pointer(childHWND)), &childPID)
54 |
55 | // compare it to the parent's - if they're different, add the child window's process to our list of process names
56 | if childPID != *ownerPID {
57 |
58 | // warning: this can silently fail, needs to be tested more thoroughly and possibly reverted in the future
59 | actualProcess, err := ps.FindProcess(int(childPID))
60 | if err == nil {
61 | result = append(result, actualProcess.Executable())
62 | }
63 | }
64 |
65 | // indicates to the system to keep iterating
66 | return 1
67 | }
68 |
69 | // get the current foreground window
70 | hwnd := win.GetForegroundWindow()
71 | var ownerPID uint32
72 |
73 | // get its PID and put it in our window info struct
74 | win.GetWindowThreadProcessId(hwnd, &ownerPID)
75 |
76 | // check for system PID (0)
77 | if ownerPID == 0 {
78 | return nil, nil
79 | }
80 |
81 | // find the process name corresponding to the parent PID
82 | process, err := ps.FindProcess(int(ownerPID))
83 | if err != nil {
84 | return nil, fmt.Errorf("get parent process for pid %d: %w", ownerPID, err)
85 | }
86 |
87 | // add it to our result slice
88 | result = append(result, process.Executable())
89 |
90 | // iterate its child windows, adding their names too
91 | win.EnumChildWindows(hwnd, syscall.NewCallback(enumChildWindowsCallback), (uintptr)(unsafe.Pointer(&ownerPID)))
92 |
93 | // cache & return whichever executable names we ended up with
94 | lastGetCurrentWindowResult = result
95 | return result, nil
96 | }
97 |
--------------------------------------------------------------------------------