Merge branch 'feature/pluggable' into develop

This commit is contained in:
Mark van Renswoude 2021-03-06 10:57:21 +01:00
commit f518115bc7
198 changed files with 15529 additions and 5913 deletions

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
.vs/
bin/
obj/
Windows/packages/
Windows/Release/
*.user

7
.gitmodules vendored Normal file
View File

@ -0,0 +1,7 @@
[submodule "Windows/min.NET"]
path = Windows/min.NET
url = https://github.com/MvRens/min.NET
[submodule "Windows/VoicemeeterRemote"]
path = Windows/VoicemeeterRemote
url = https://github.com/MvRens/VoicemeeterRemote

View File

@ -5,24 +5,49 @@
*
*/
// Set this to the number of potentiometers you have connected
const byte KnobCount = 1;
const byte AnalogInputCount = 3;
// For each potentiometer, specify the port
const byte KnobPin[KnobCount] = {
// A0,
A1
// Set this to the number of buttons you have connected
const byte DigitalInputCount = 0;
// Not supported yet - maybe PWM and/or other means of analog output?
const byte AnalogOutputCount = 0;
// Set this to the number of digital outputs you have connected
const byte DigitalOutputCount = 0;
// For each potentiometer, specify the pin
const byte AnalogInputPin[AnalogInputCount] = {
A0,
A1,
A2
};
// Minimum time between reporting changing values, reduces serial traffic
// For each button, specify the pin. Assumes pull-up.
const byte DigitalInputPin[DigitalInputCount] = {
};
// For each digital output, specify the pin
const byte DigitalOutputPin[DigitalOutputCount] = {
};
// Minimum time between reporting changing values, reduces serial traffic and debounces digital inputs
const unsigned long MinimumInterval = 50;
// Alpha value of the Exponential Moving Average (EMA) to reduce noise
// Alpha value of the Exponential Moving Average (EMA) for analog inputs to reduce noise
const float EMAAlpha = 0.6;
// How many measurements to take at boot time to seed the EMA
// How many measurements to take at boot time for analog inputs to seed the EMA
const byte EMASeedCount = 5;
// Minimum treshold for reporting changes in analog values, reduces noise left over from the EMA. Note that once an analog value
// changes beyond the treshold, that input will report all changes until the FocusTimeout has expired to avoid losing accuracy.
const byte AnalogTreshold = 2;
// How long to ignore other inputs after an input changes. Reduces noise due voltage drops.
const unsigned long FocusTimeout = 100;
/*
@ -31,20 +56,59 @@ const byte EMASeedCount = 5;
* Here be dragons.
*
*/
bool active = false;
enum OutputMode {
Binary, // Communication with the desktop application
PlainText, // Plain text, useful for the Arduino Serial Monitor
Plotter // Graph values, for the Arduino Serial Plotter
};
OutputMode outputMode = Binary;
byte volume[KnobCount];
unsigned long lastChange[KnobCount];
int analogReadValue[KnobCount];
float emaValue[KnobCount];
unsigned long currentTime;
unsigned long lastPlot;
// If defined, only outputs will be sent to the serial port as Arduino Plotter compatible data
//#define DebugOutputPlotter
#ifndef DebugOutputPlotter
#include "./min.h"
#include "./min.c"
// MIN protocol context and callbacks
struct min_context minContext;
uint16_t min_tx_space(uint8_t port) { return 512U; }
void min_tx_byte(uint8_t port, uint8_t byte)
{
while (Serial.write(&byte, 1U) == 0) { }
}
uint32_t min_time_ms(void) { return millis(); }
void min_application_handler(uint8_t min_id, uint8_t *min_payload, uint8_t len_payload, uint8_t port);
void min_tx_start(uint8_t port) {}
void min_tx_finished(uint8_t port) {}
const uint8_t FrameIDHandshake = 42;
const uint8_t FrameIDHandshakeResponse = 43;
const uint8_t FrameIDAnalogInput = 1;
const uint8_t FrameIDDigitalInput = 2;
const uint8_t FrameIDAnalogOutput = 3;
const uint8_t FrameIDDigitalOutput = 4;
const uint8_t FrameIDQuit = 62;
const uint8_t FrameIDError = 63;
#endif
struct AnalogInputStatus
{
byte Value;
unsigned long LastChange;
int ReadValue;
float EMAValue;
};
struct DigitalInputStatus
{
bool Value;
unsigned long LastChange;
};
struct AnalogInputStatus analogInputStatus[AnalogInputCount];
struct DigitalInputStatus digitalInputStatus[AnalogInputCount];
void setup()
{
@ -54,177 +118,303 @@ void setup()
while (!Serial) {}
// Seed the moving average
for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++)
emaValue[knobIndex] = analogRead(KnobPin[knobIndex]);
for (byte seed = 1; seed < EMASeedCount - 1; seed++)
for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++)
getVolume(knobIndex);
#ifndef DebugOutputPlotter
// Set up the MIN protocol (http://github.com/min-protocol/min)
min_init_context(&minContext, 0);
#endif
// Read the initial values
currentTime = millis();
for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++)
// Seed the moving average for analog inputs
for (byte i = 0; i < AnalogInputCount; i++)
{
volume[knobIndex] = getVolume(knobIndex);
lastChange[knobIndex] = currentTime;
pinMode(AnalogInputPin[i], INPUT);
analogInputStatus[i].EMAValue = analogRead(AnalogInputPin[i]);
}
for (byte i = 0; i < AnalogInputCount; i++)
for (byte seed = 1; seed < EMASeedCount - 1; seed++)
getAnalogValue(i);
// Read the initial stabilized values
for (byte i = 0; i < AnalogInputCount; i++)
{
analogInputStatus[i].Value = getAnalogValue(i);
analogInputStatus[i].LastChange = millis();
}
// Set up digital inputs and outputs
for (byte i = 0; i < DigitalInputCount; i++)
{
pinMode(DigitalInputPin[i], INPUT_PULLUP);
digitalInputStatus[i].Value = getDigitalValue(i);
digitalInputStatus[i].LastChange = millis();
}
for (byte i = 0; i < DigitalOutputCount; i++)
{
pinMode(DigitalOutputPin[i], OUTPUT);
digitalWrite(DigitalOutputPin[i], LOW);
}
}
#ifdef DebugOutputPlotter
unsigned long lastOutput = 0;
#endif
enum FocusType
{
FocusTypeNone = 0,
FocusTypeAnalogInput = 1,
FocusTypeDigitalInput = 2,
FocusTypeOutput = 3
};
bool active = false;
FocusType focusType = FocusTypeNone;
byte focusInputIndex;
unsigned long focusOutputTime;
#define IsAnalogInputFocus(i) ((focusType == FocusInputType.AnalogInput) && (focusInputIndex == i))
#define IsDigitalInputFocus(i) ((focusType == FocusInputType.DigitalInput) && (focusInputIndex == i))
void loop()
{
if (Serial.available())
processMessage(Serial.read());
#ifndef DebugOutputPlotter
char readBuffer[32];
size_t readBufferSize = Serial.available() > 0 ? Serial.readBytes(readBuffer, 32U) : 0;
min_poll(&minContext, (uint8_t*)readBuffer, (uint8_t)readBufferSize);
#endif
// Not that due to ADC checking and Serial communication, currentTime will not be
// accurate throughout the loop. But since we don't need exact timing for the interval this
// is acceptable and saves a few calls to millis.
currentTime = millis();
// Check volume knobs
byte newVolume;
for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++)
if (focusType == FocusTypeOutput && millis() - focusOutputTime >= FocusTimeout)
focusType = FocusTypeNone;
// Check analog inputs
byte newAnalogValue;
for (byte i = 0; i < AnalogInputCount; i++)
{
newVolume = getVolume(knobIndex);
newAnalogValue = getAnalogValue(i);
bool changed;
if (newVolume != volume[knobIndex] && (currentTime - lastChange[knobIndex] >= MinimumInterval))
switch (focusType)
{
case FocusTypeAnalogInput:
if (focusInputIndex != i)
continue;
if (millis() - analogInputStatus[i].LastChange < FocusTimeout)
{
changed = newAnalogValue != analogInputStatus[i].Value;
break;
}
else
focusType = FocusTypeNone;
// fall-through
case FocusTypeNone:
changed = abs(analogInputStatus[i].Value - newAnalogValue) >= AnalogTreshold;
break;
default:
continue;
}
if (changed && (millis() - analogInputStatus[i].LastChange >= MinimumInterval))
{
if (active)
// Send out new value
outputVolume(knobIndex, newVolume);
outputAnalogValue(i, newAnalogValue);
volume[knobIndex] = newVolume;
lastChange[knobIndex] = currentTime;
analogInputStatus[i].Value = newAnalogValue;
analogInputStatus[i].LastChange = millis();
}
}
if (outputMode == Plotter && (currentTime - lastPlot) >= 50)
// Check digital inputs
bool newDigitalValue;
for (byte i = 0; i < DigitalInputCount; i++)
{
outputPlotter();
lastPlot = currentTime;
newDigitalValue = getDigitalValue(i);
switch (focusType)
{
case FocusTypeAnalogInput:
case FocusTypeOutput:
continue;
case FocusTypeDigitalInput:
if (focusInputIndex != i)
continue;
if (millis() - digitalInputStatus[i].LastChange >= FocusTimeout)
focusType = FocusTypeNone;
break;
}
if (newDigitalValue != digitalInputStatus[i].Value && (millis() - digitalInputStatus[i].LastChange >= MinimumInterval))
{
if (active)
// Send out new value
outputDigitalValue(i, newDigitalValue);
digitalInputStatus[i].Value = newDigitalValue;
digitalInputStatus[i].LastChange = millis();
}
}
#ifdef DebugOutputPlotter
if (millis() - lastOutput >= 100)
{
for (byte i = 0; i < AnalogInputCount; i++)
{
if (i > 0)
Serial.print("\t");
Serial.print(analogInputStatus[i].Value);
}
for (byte i = 0; i < DigitalInputCount; i++)
{
if (i > 0 || AnalogInputCount > 0)
Serial.print("\t");
Serial.print(digitalInputStatus[i].Value ? 100 : 0);
}
Serial.println();
}
#endif
}
void processMessage(byte message)
#ifndef DebugOutputPlotter
void min_application_handler(uint8_t min_id, uint8_t *min_payload, uint8_t len_payload, uint8_t port)
{
switch (message)
switch (min_id)
{
case 'H': // Handshake
processHandshakeMessage();
case FrameIDHandshake:
processHandshakeMessage(min_payload, len_payload);
break;
case 'Q': // Quit
case FrameIDAnalogOutput:
//processAnalogOutputMessage();
break;
case FrameIDDigitalOutput:
processDigitalOutputMessage(min_payload, len_payload);
break;
case FrameIDQuit:
processQuitMessage();
break;
}
}
void processHandshakeMessage()
{
byte buffer[2];
if (Serial.readBytes(buffer, 3) < 3)
return;
if (buffer[0] != 'M' || buffer[1] != 'K')
return;
switch (buffer[2])
{
case 'B':
outputMode = Binary;
break;
case 'P':
outputMode = PlainText;
break;
case 'G':
outputMode = Plotter;
break;
default:
return;
outputError("Unknown frame ID: " + String(min_id));
break;
}
}
switch (outputMode)
void processHandshakeMessage(uint8_t *min_payload, uint8_t len_payload)
{
if (len_payload < 2)
{
case Binary:
Serial.write('H');
Serial.write(KnobCount);
break;
case PlainText:
Serial.print("Hello! I have ");
Serial.print(KnobCount);
Serial.println(" knobs.");
break;
outputError("Invalid handshake length");
return;
}
if (min_payload[0] != 'M' || min_payload[1] != 'K')
{
outputError("Invalid handshake: " + String((char)min_payload[0]) + String((char)min_payload[1]));
return;
}
active = true;
byte payload[4] { AnalogInputCount, DigitalInputCount, AnalogOutputCount, DigitalOutputCount };
if (min_queue_frame(&minContext, FrameIDHandshakeResponse, (uint8_t *)payload, 4))
active = true;
}
void processDigitalOutputMessage(uint8_t *min_payload, uint8_t len_payload)
{
if (len_payload < 2)
{
outputError("Invalid digital output payload length");
return;
}
byte outputIndex = min_payload[0];
if (outputIndex < DigitalOutputCount)
{
digitalWrite(DigitalOutputPin[min_payload[0]], min_payload[1] == 0 ? LOW : HIGH);
focusType = FocusTypeOutput;
focusOutputTime = millis();
}
else
outputError("Invalid digital output index: " + String(outputIndex));
}
void processQuitMessage()
{
switch (outputMode)
{
case Binary:
case PlainText:
Serial.write('Q');
break;
}
{
active = false;
}
#endif
byte getVolume(byte knobIndex)
byte getAnalogValue(byte analogInputIndex)
{
analogReadValue[knobIndex] = analogRead(KnobPin[knobIndex]);
emaValue[knobIndex] = (EMAAlpha * analogReadValue[knobIndex]) + ((1 - EMAAlpha) * emaValue[knobIndex]);
analogRead(AnalogInputPin[analogInputIndex]);
return map(emaValue[knobIndex], 0, 1023, 0, 100);
// Give the ADC some time to stabilize
delay(10);
int readValue = analogRead(AnalogInputPin[analogInputIndex]);
analogInputStatus[analogInputIndex].ReadValue = readValue;
int newEMAValue = (EMAAlpha * readValue) + ((1 - EMAAlpha) * analogInputStatus[analogInputIndex].EMAValue);
analogInputStatus[analogInputIndex].EMAValue = newEMAValue;
return map(newEMAValue, 0, 1023, 0, 100);
}
void outputVolume(byte knobIndex, byte newVolume)
bool getDigitalValue(byte digitalInputIndex)
{
switch (outputMode)
{
case Binary:
Serial.write('V');
Serial.write(knobIndex);
Serial.write(newVolume);
break;
case PlainText:
Serial.print("Volume #");
Serial.print(knobIndex);
Serial.print(" = ");
Serial.println(newVolume);
break;
}
return digitalRead(DigitalInputPin[digitalInputIndex]) == LOW;
}
void outputPlotter()
void outputAnalogValue(byte analogInputIndex, byte newValue)
{
for (byte i = 0; i < KnobCount; i++)
{
if (i > 0)
Serial.print('\t');
Serial.print(analogReadValue[i]);
Serial.print('\t');
Serial.print(emaValue[i]);
Serial.print('\t');
Serial.print(volume[i]);
}
Serial.println();
#ifndef DebugOutputPlotter
byte payload[2] = { analogInputIndex, newValue };
min_send_frame(&minContext, FrameIDAnalogInput, (uint8_t *)payload, 2);
#endif
}
void outputDigitalValue(byte digitalInputIndex, bool newValue)
{
#ifndef DebugOutputPlotter
byte payload[2] = { digitalInputIndex, newValue ? 1 : 0 };
min_send_frame(&minContext, FrameIDDigitalInput, (uint8_t *)payload, 2);
#endif
}
void outputError(String message)
{
#ifndef DebugOutputPlotter
min_send_frame(&minContext, FrameIDError, (uint8_t *)message.c_str(), message.length());
#endif
}

641
Arduino/MassiveKnob/min.c Normal file
View File

@ -0,0 +1,641 @@
// Copyright (c) 2014-2017 JK Energy Ltd.
//
// Use authorized under the MIT license.
#include "min.h"
#define TRANSPORT_FIFO_SIZE_FRAMES_MASK ((uint8_t)((1U << TRANSPORT_FIFO_SIZE_FRAMES_BITS) - 1U))
#define TRANSPORT_FIFO_SIZE_FRAME_DATA_MASK ((uint16_t)((1U << TRANSPORT_FIFO_SIZE_FRAME_DATA_BITS) - 1U))
// Number of bytes needed for a frame with a given payload length, excluding stuff bytes
// 3 header bytes, ID/control byte, length byte, seq byte, 4 byte CRC, EOF byte
#define ON_WIRE_SIZE(p) ((p) + 11U)
// Special protocol bytes
enum {
HEADER_BYTE = 0xaaU,
STUFF_BYTE = 0x55U,
EOF_BYTE = 0x55U,
};
// Receiving state machine
enum {
SEARCHING_FOR_SOF,
RECEIVING_ID_CONTROL,
RECEIVING_SEQ,
RECEIVING_LENGTH,
RECEIVING_PAYLOAD,
RECEIVING_CHECKSUM_3,
RECEIVING_CHECKSUM_2,
RECEIVING_CHECKSUM_1,
RECEIVING_CHECKSUM_0,
RECEIVING_EOF,
};
#ifdef TRANSPORT_PROTOCOL
#ifndef TRANSPORT_ACK_RETRANSMIT_TIMEOUT_MS
#define TRANSPORT_ACK_RETRANSMIT_TIMEOUT_MS (25U)
#endif
#ifndef TRANSPORT_FRAME_RETRANSMIT_TIMEOUT_MS
#define TRANSPORT_FRAME_RETRANSMIT_TIMEOUT_MS (50U) // Should be long enough for a whole window to be transmitted plus an ACK / NACK to get back
#endif
#ifndef TRANSPORT_MAX_WINDOW_SIZE
#define TRANSPORT_MAX_WINDOW_SIZE (16U)
#endif
#ifndef TRANSPORT_IDLE_TIMEOUT_MS
#define TRANSPORT_IDLE_TIMEOUT_MS (1000U)
#endif
enum {
// Top bit must be set: these are for the transport protocol to use
// 0x7f and 0x7e are reserved MIN identifiers.
ACK = 0xffU,
RESET = 0xfeU,
};
// Where the payload data of the frame FIFO is stored
uint8_t payloads_ring_buffer[TRANSPORT_FIFO_MAX_FRAME_DATA];
static uint32_t now;
static void send_reset(struct min_context *self);
#endif
static void crc32_init_context(struct crc32_context *context)
{
context->crc = 0xffffffffU;
}
static void crc32_step(struct crc32_context *context, uint8_t byte)
{
context->crc ^= byte;
for(uint32_t j = 0; j < 8; j++) {
uint32_t mask = (uint32_t) -(context->crc & 1U);
context->crc = (context->crc >> 1) ^ (0xedb88320U & mask);
}
}
static uint32_t crc32_finalize(struct crc32_context *context)
{
return ~context->crc;
}
static void stuffed_tx_byte(struct min_context *self, uint8_t byte)
{
// Transmit the byte
min_tx_byte(self->port, byte);
crc32_step(&self->tx_checksum, byte);
// See if an additional stuff byte is needed
if(byte == HEADER_BYTE) {
if(--self->tx_header_byte_countdown == 0) {
min_tx_byte(self->port, STUFF_BYTE); // Stuff byte
self->tx_header_byte_countdown = 2U;
}
}
else {
self->tx_header_byte_countdown = 2U;
}
}
static void on_wire_bytes(struct min_context *self, uint8_t id_control, uint8_t seq, uint8_t *payload_base, uint16_t payload_offset, uint16_t payload_mask, uint8_t payload_len)
{
uint8_t n, i;
uint32_t checksum;
self->tx_header_byte_countdown = 2U;
crc32_init_context(&self->tx_checksum);
min_tx_start(self->port);
// Header is 3 bytes; because unstuffed will reset receiver immediately
min_tx_byte(self->port, HEADER_BYTE);
min_tx_byte(self->port, HEADER_BYTE);
min_tx_byte(self->port, HEADER_BYTE);
stuffed_tx_byte(self, id_control);
if(id_control & 0x80U) {
// Send the sequence number if it is a transport frame
stuffed_tx_byte(self, seq);
}
stuffed_tx_byte(self, payload_len);
for(i = 0, n = payload_len; n > 0; n--, i++) {
stuffed_tx_byte(self, payload_base[payload_offset]);
payload_offset++;
payload_offset &= payload_mask;
}
checksum = crc32_finalize(&self->tx_checksum);
// Network order is big-endian. A decent C compiler will spot that this
// is extracting bytes and will use efficient instructions.
stuffed_tx_byte(self, (uint8_t)((checksum >> 24) & 0xffU));
stuffed_tx_byte(self, (uint8_t)((checksum >> 16) & 0xffU));
stuffed_tx_byte(self, (uint8_t)((checksum >> 8) & 0xffU));
stuffed_tx_byte(self, (uint8_t)((checksum >> 0) & 0xffU));
// Ensure end-of-frame doesn't contain 0xaa and confuse search for start-of-frame
min_tx_byte(self->port, EOF_BYTE);
min_tx_finished(self->port);
}
#ifdef TRANSPORT_PROTOCOL
// Pops frame from front of queue, reclaims its ring buffer space
static void transport_fifo_pop(struct min_context *self)
{
#ifdef ASSERTION_CHECKING
assert(self->transport_fifo.n_frames != 0);
#endif
struct transport_frame *frame = &self->transport_fifo.frames[self->transport_fifo.head_idx];
min_debug_print("Popping frame id=%d seq=%d\n", frame->min_id, frame->seq);
#ifdef ASSERTION_CHECKING
assert(self->transport_fifo.n_ring_buffer_bytes >= frame->payload_len);
#endif
self->transport_fifo.n_frames--;
self->transport_fifo.head_idx++;
self->transport_fifo.head_idx &= TRANSPORT_FIFO_SIZE_FRAMES_MASK;
self->transport_fifo.n_ring_buffer_bytes -= frame->payload_len;
}
// Claim a buffer slot from the FIFO. Returns 0 if there is no space.
static struct transport_frame *transport_fifo_push(struct min_context *self, uint16_t data_size)
{
// A frame is only queued if there aren't too many frames in the FIFO and there is space in the
// data ring buffer.
struct transport_frame *ret = 0;
if (self->transport_fifo.n_frames < TRANSPORT_FIFO_MAX_FRAMES) {
// Is there space in the ring buffer for the frame payload?
if(self->transport_fifo.n_ring_buffer_bytes <= TRANSPORT_FIFO_MAX_FRAME_DATA - data_size) {
self->transport_fifo.n_frames++;
if (self->transport_fifo.n_frames > self->transport_fifo.n_frames_max) {
// High-water mark of FIFO (for diagnostic purposes)
self->transport_fifo.n_frames_max = self->transport_fifo.n_frames;
}
// Create FIFO entry
ret = &(self->transport_fifo.frames[self->transport_fifo.tail_idx]);
ret->payload_offset = self->transport_fifo.ring_buffer_tail_offset;
// Claim ring buffer space
self->transport_fifo.n_ring_buffer_bytes += data_size;
if(self->transport_fifo.n_ring_buffer_bytes > self->transport_fifo.n_ring_buffer_bytes_max) {
// High-water mark of ring buffer usage (for diagnostic purposes)
self->transport_fifo.n_ring_buffer_bytes_max = self->transport_fifo.n_ring_buffer_bytes;
}
self->transport_fifo.ring_buffer_tail_offset += data_size;
self->transport_fifo.ring_buffer_tail_offset &= TRANSPORT_FIFO_SIZE_FRAME_DATA_MASK;
// Claim FIFO space
self->transport_fifo.tail_idx++;
self->transport_fifo.tail_idx &= TRANSPORT_FIFO_SIZE_FRAMES_MASK;
}
else {
min_debug_print("No FIFO payload space: data_size=%d, n_ring_buffer_bytes=%d\n", data_size, self->transport_fifo.n_ring_buffer_bytes);
}
}
else {
min_debug_print("No FIFO frame slots\n");
}
return ret;
}
// Return the nth frame in the FIFO
static struct transport_frame *transport_fifo_get(struct min_context *self, uint8_t n)
{
uint8_t idx = self->transport_fifo.head_idx;
return &self->transport_fifo.frames[(idx + n) & TRANSPORT_FIFO_SIZE_FRAMES_MASK];
}
// Sends the given frame to the serial line
static void transport_fifo_send(struct min_context *self, struct transport_frame *frame)
{
min_debug_print("transport_fifo_send: min_id=%d, seq=%d, payload_len=%d\n", frame->min_id, frame->seq, frame->payload_len);
on_wire_bytes(self, frame->min_id | (uint8_t)0x80U, frame->seq, payloads_ring_buffer, frame->payload_offset, TRANSPORT_FIFO_SIZE_FRAME_DATA_MASK, frame->payload_len);
frame->last_sent_time_ms = now;
}
// We don't queue an ACK frame - we send it straight away (if there's space to do so)
static void send_ack(struct min_context *self)
{
// In the embedded end we don't reassemble out-of-order frames and so never ask for retransmits. Payload is
// always the same as the sequence number.
min_debug_print("send ACK: seq=%d\n", self->transport_fifo.rn);
if(ON_WIRE_SIZE(0) <= min_tx_space(self->port)) {
on_wire_bytes(self, ACK, self->transport_fifo.rn, &self->transport_fifo.rn, 0, 0xffU, 1U);
self->transport_fifo.last_sent_ack_time_ms = now;
}
}
// We don't queue an RESET frame - we send it straight away (if there's space to do so)
static void send_reset(struct min_context *self)
{
min_debug_print("send RESET\n");
if(ON_WIRE_SIZE(0) <= min_tx_space(self->port)) {
on_wire_bytes(self, RESET, 0, 0, 0, 0, 0);
}
}
static void transport_fifo_reset(struct min_context *self)
{
// Clear down the transmission FIFO queue
self->transport_fifo.n_frames = 0;
self->transport_fifo.head_idx = 0;
self->transport_fifo.tail_idx = 0;
self->transport_fifo.n_ring_buffer_bytes = 0;
self->transport_fifo.ring_buffer_tail_offset = 0;
self->transport_fifo.sn_max = 0;
self->transport_fifo.sn_min = 0;
self->transport_fifo.rn = 0;
// Reset the timers
self->transport_fifo.last_received_anything_ms = now;
self->transport_fifo.last_sent_ack_time_ms = now;
self->transport_fifo.last_received_frame_ms = 0;
}
void min_transport_reset(struct min_context *self, bool inform_other_side)
{
if (inform_other_side) {
// Tell the other end we have gone away
send_reset(self);
}
// Throw our frames away
transport_fifo_reset(self);
}
// Queues a MIN ID / payload frame into the outgoing FIFO
// API call.
// Returns true if the frame was queued OK.
bool min_queue_frame(struct min_context *self, uint8_t min_id, uint8_t *payload, uint8_t payload_len)
{
struct transport_frame *frame = transport_fifo_push(self, payload_len); // Claim a FIFO slot, reserve space for payload
// We are just queueing here: the poll() function puts the frame into the window and on to the wire
if(frame != 0) {
// Copy frame details into frame slot, copy payload into ring buffer
frame->min_id = min_id & (uint8_t)0x3fU;
frame->payload_len = payload_len;
uint16_t payload_offset = frame->payload_offset;
for(uint32_t i = 0; i < payload_len; i++) {
payloads_ring_buffer[payload_offset] = payload[i];
payload_offset++;
payload_offset &= TRANSPORT_FIFO_SIZE_FRAME_DATA_MASK;
}
min_debug_print("Queued ID=%d, len=%d\n", min_id, payload_len);
return true;
}
else {
self->transport_fifo.dropped_frames++;
return false;
}
}
bool min_queue_has_space_for_frame(struct min_context *self, uint8_t payload_len) {
return self->transport_fifo.n_frames < TRANSPORT_FIFO_MAX_FRAMES &&
self->transport_fifo.n_ring_buffer_bytes <= TRANSPORT_FIFO_MAX_FRAME_DATA - payload_len;
}
// Finds the frame in the window that was sent least recently
static struct transport_frame *find_retransmit_frame(struct min_context *self)
{
uint8_t window_size = self->transport_fifo.sn_max - self->transport_fifo.sn_min;
#ifdef ASSERTION_CHECKS
assert(window_size > 0);
assert(window_size <= self->transport_fifo.nframes);
#endif
// Start with the head of the queue and call this the oldest
struct transport_frame *oldest_frame = &self->transport_fifo.frames[self->transport_fifo.head_idx];
uint32_t oldest_elapsed_time = now - oldest_frame->last_sent_time_ms;
uint8_t idx = self->transport_fifo.head_idx;
for(uint8_t i = 0; i < window_size; i++) {
uint32_t elapsed = now - self->transport_fifo.frames[idx].last_sent_time_ms;
if(elapsed > oldest_elapsed_time) { // Strictly older only; otherwise the earlier frame is deemed the older
oldest_elapsed_time = elapsed;
oldest_frame = &self->transport_fifo.frames[idx];
}
idx++;
idx &= TRANSPORT_FIFO_SIZE_FRAMES_MASK;
}
return oldest_frame;
}
#endif // TRANSPORT_PROTOCOL
// This runs the receiving half of the transport protocol, acknowledging frames received, discarding
// duplicates received, and handling RESET requests.
static void valid_frame_received(struct min_context *self)
{
uint8_t id_control = self->rx_frame_id_control;
uint8_t *payload = self->rx_frame_payload_buf;
uint8_t payload_len = self->rx_control;
#ifdef TRANSPORT_PROTOCOL
uint8_t seq = self->rx_frame_seq;
uint8_t num_acked;
uint8_t num_nacked;
uint8_t num_in_window;
// When we receive anything we know the other end is still active and won't shut down
self->transport_fifo.last_received_anything_ms = now;
switch(id_control) {
case ACK:
// If we get an ACK then we remove all the acknowledged frames with seq < rn
// The payload byte specifies the number of NACKed frames: how many we want retransmitted because
// they have gone missing.
// But we need to make sure we don't accidentally ACK too many because of a stale ACK from an old session
num_acked = seq - self->transport_fifo.sn_min;
num_nacked = payload[0] - seq;
num_in_window = self->transport_fifo.sn_max - self->transport_fifo.sn_min;
if(num_acked <= num_in_window) {
self->transport_fifo.sn_min = seq;
#ifdef ASSERTION_CHECKING
assert(self->transport_fifo.n_frames >= num_in_window);
assert(num_in_window <= TRANSPORT_MAX_WINDOW_SIZE);
assert(num_nacked <= TRANSPORT_MAX_WINDOW_SIZE);
#endif
// Now pop off all the frames up to (but not including) rn
// The ACK contains Rn; all frames before Rn are ACKed and can be removed from the window
min_debug_print("Received ACK seq=%d, num_acked=%d, num_nacked=%d\n", seq, num_acked, num_nacked);
for(uint8_t i = 0; i < num_acked; i++) {
transport_fifo_pop(self);
}
uint8_t idx = self->transport_fifo.head_idx;
// Now retransmit the number of frames that were requested
for(uint8_t i = 0; i < num_nacked; i++) {
struct transport_frame *retransmit_frame = &self->transport_fifo.frames[idx];
transport_fifo_send(self, retransmit_frame);
idx++;
idx &= TRANSPORT_FIFO_SIZE_FRAMES_MASK;
}
}
else {
min_debug_print("Received spurious ACK seq=%d\n", seq);
self->transport_fifo.spurious_acks++;
}
break;
case RESET:
// If we get a RESET demand then we reset the transport protocol (empty the FIFO, reset the
// sequence numbers, etc.)
// We don't send anything, we just do it. The other end can send frames to see if this end is
// alive (pings, etc.) or just wait to get application frames.
self->transport_fifo.resets_received++;
transport_fifo_reset(self);
break;
default:
if (id_control & 0x80U) {
// Incoming application frames
// Reset the activity time (an idle connection will be stalled)
self->transport_fifo.last_received_frame_ms = now;
if (seq == self->transport_fifo.rn) {
// Accept this frame as matching the sequence number we were looking for
// Now looking for the next one in the sequence
self->transport_fifo.rn++;
// Always send an ACK back for the frame we received
// ACKs are short (should be about 9 microseconds to send on the wire) and
// this will cut the latency down.
// We also periodically send an ACK in case the ACK was lost, and in any case
// frames are re-sent.
send_ack(self);
// Now ready to pass this up to the application handlers
// Pass frame up to application handler to deal with
min_debug_print("Incoming app frame seq=%d, id=%d, payload len=%d\n", seq, id_control & (uint8_t)0x3fU, payload_len);
min_application_handler(id_control & (uint8_t)0x3fU, payload, payload_len, self->port);
} else {
// Discard this frame because we aren't looking for it: it's either a dupe because it was
// retransmitted when our ACK didn't get through in time, or else it's further on in the
// sequence and others got dropped.
self->transport_fifo.sequence_mismatch_drop++;
}
}
else {
// Not a transport frame
min_application_handler(id_control & (uint8_t)0x3fU, payload, payload_len, self->port);
}
break;
}
#else // TRANSPORT_PROTOCOL
min_application_handler(id_control & (uint8_t)0x3fU, payload, payload_len, self->port);
#endif // TRANSPORT_PROTOCOL
}
static void rx_byte(struct min_context *self, uint8_t byte)
{
// Regardless of state, three header bytes means "start of frame" and
// should reset the frame buffer and be ready to receive frame data
//
// Two in a row in over the frame means to expect a stuff byte.
uint32_t crc;
if(self->rx_header_bytes_seen == 2) {
self->rx_header_bytes_seen = 0;
if(byte == HEADER_BYTE) {
self->rx_frame_state = RECEIVING_ID_CONTROL;
return;
}
if(byte == STUFF_BYTE) {
/* Discard this byte; carry on receiving on the next character */
return;
}
else {
/* Something has gone wrong, give up on this frame and look for header again */
self->rx_frame_state = SEARCHING_FOR_SOF;
return;
}
}
if(byte == HEADER_BYTE) {
self->rx_header_bytes_seen++;
}
else {
self->rx_header_bytes_seen = 0;
}
switch(self->rx_frame_state) {
case SEARCHING_FOR_SOF:
break;
case RECEIVING_ID_CONTROL:
self->rx_frame_id_control = byte;
self->rx_frame_payload_bytes = 0;
crc32_init_context(&self->rx_checksum);
crc32_step(&self->rx_checksum, byte);
if(byte & 0x80U) {
#ifdef TRANSPORT_PROTOCOL
self->rx_frame_state = RECEIVING_SEQ;
#else
// If there is no transport support compiled in then all transport frames are ignored
self->rx_frame_state = SEARCHING_FOR_SOF;
#endif // TRANSPORT_PROTOCOL
}
else {
self->rx_frame_seq = 0;
self->rx_frame_state = RECEIVING_LENGTH;
}
break;
case RECEIVING_SEQ:
self->rx_frame_seq = byte;
crc32_step(&self->rx_checksum, byte);
self->rx_frame_state = RECEIVING_LENGTH;
break;
case RECEIVING_LENGTH:
self->rx_frame_length = byte;
self->rx_control = byte;
crc32_step(&self->rx_checksum, byte);
if(self->rx_frame_length > 0) {
// Can reduce the RAM size by compiling limits to frame sizes
if(self->rx_frame_length <= MAX_PAYLOAD) {
self->rx_frame_state = RECEIVING_PAYLOAD;
}
else {
// Frame dropped because it's longer than any frame we can buffer
self->rx_frame_state = SEARCHING_FOR_SOF;
}
}
else {
self->rx_frame_state = RECEIVING_CHECKSUM_3;
}
break;
case RECEIVING_PAYLOAD:
self->rx_frame_payload_buf[self->rx_frame_payload_bytes++] = byte;
crc32_step(&self->rx_checksum, byte);
if(--self->rx_frame_length == 0) {
self->rx_frame_state = RECEIVING_CHECKSUM_3;
}
break;
case RECEIVING_CHECKSUM_3:
self->rx_frame_checksum = ((uint32_t)byte) << 24;
self->rx_frame_state = RECEIVING_CHECKSUM_2;
break;
case RECEIVING_CHECKSUM_2:
self->rx_frame_checksum |= ((uint32_t)byte) << 16;
self->rx_frame_state = RECEIVING_CHECKSUM_1;
break;
case RECEIVING_CHECKSUM_1:
self->rx_frame_checksum |= ((uint32_t)byte) << 8;
self->rx_frame_state = RECEIVING_CHECKSUM_0;
break;
case RECEIVING_CHECKSUM_0:
self->rx_frame_checksum |= byte;
crc = crc32_finalize(&self->rx_checksum);
if(self->rx_frame_checksum != crc) {
// Frame fails the checksum and so is dropped
self->rx_frame_state = SEARCHING_FOR_SOF;
}
else {
// Checksum passes, go on to check for the end-of-frame marker
self->rx_frame_state = RECEIVING_EOF;
}
break;
case RECEIVING_EOF:
if(byte == 0x55u) {
// Frame received OK, pass up data to handler
valid_frame_received(self);
}
// else discard
// Look for next frame */
self->rx_frame_state = SEARCHING_FOR_SOF;
break;
default:
// Should never get here but in case we do then reset to a safe state
self->rx_frame_state = SEARCHING_FOR_SOF;
break;
}
}
// API call: sends received bytes into a MIN context and runs the transport timeouts
void min_poll(struct min_context *self, uint8_t *buf, uint32_t buf_len)
{
for(uint32_t i = 0; i < buf_len; i++) {
rx_byte(self, buf[i]);
}
#ifdef TRANSPORT_PROTOCOL
uint8_t window_size;
now = min_time_ms();
bool remote_connected = (now - self->transport_fifo.last_received_anything_ms < TRANSPORT_IDLE_TIMEOUT_MS);
bool remote_active = (now - self->transport_fifo.last_received_frame_ms < TRANSPORT_IDLE_TIMEOUT_MS);
// This sends one new frame or resends one old frame
window_size = self->transport_fifo.sn_max - self->transport_fifo.sn_min; // Window size
if((window_size < TRANSPORT_MAX_WINDOW_SIZE) && (self->transport_fifo.n_frames > window_size)) {
// There are new frames we can send; but don't even bother if there's no buffer space for them
struct transport_frame *frame = transport_fifo_get(self, window_size);
if(ON_WIRE_SIZE(frame->payload_len) <= min_tx_space(self->port)) {
frame->seq = self->transport_fifo.sn_max;
transport_fifo_send(self, frame);
// Move window on
self->transport_fifo.sn_max++;
}
}
else {
// Sender cannot send new frames so resend old ones (if there's anyone there)
if((window_size > 0) && remote_connected) {
// There are unacknowledged frames. Can re-send an old frame. Pick the least recently sent one.
struct transport_frame *oldest_frame = find_retransmit_frame(self);
if(now - oldest_frame->last_sent_time_ms >= TRANSPORT_FRAME_RETRANSMIT_TIMEOUT_MS) {
// Resending oldest frame if there's a chance there's enough space to send it
if(ON_WIRE_SIZE(oldest_frame->payload_len) <= min_tx_space(self->port)) {
transport_fifo_send(self, oldest_frame);
}
}
}
}
#ifndef DISABLE_TRANSPORT_ACK_RETRANSMIT
// Periodically transmit the ACK with the rn value, unless the line has gone idle
if(now - self->transport_fifo.last_sent_ack_time_ms > TRANSPORT_ACK_RETRANSMIT_TIMEOUT_MS) {
if(remote_active) {
send_ack(self);
}
}
#endif // DISABLE_TRANSPORT_ACK_RETRANSMIT
#endif // TRANSPORT_PROTOCOL
}
void min_init_context(struct min_context *self, uint8_t port)
{
// Initialize context
self->rx_header_bytes_seen = 0;
self->rx_frame_state = SEARCHING_FOR_SOF;
self->port = port;
#ifdef TRANSPORT_PROTOCOL
// Counters for diagnosis purposes
self->transport_fifo.spurious_acks = 0;
self->transport_fifo.sequence_mismatch_drop = 0;
self->transport_fifo.dropped_frames = 0;
self->transport_fifo.resets_received = 0;
self->transport_fifo.n_ring_buffer_bytes_max = 0;
self->transport_fifo.n_frames_max = 0;
transport_fifo_reset(self);
#endif // TRANSPORT_PROTOCOL
}
// Sends an application MIN frame on the wire (do not put into the transport queue)
void min_send_frame(struct min_context *self, uint8_t min_id, uint8_t *payload, uint8_t payload_len)
{
if((ON_WIRE_SIZE(payload_len) <= min_tx_space(self->port))) {
on_wire_bytes(self, min_id & (uint8_t) 0x3fU, 0, payload, 0, 0xffffU, payload_len);
}
}

208
Arduino/MassiveKnob/min.h Normal file
View File

@ -0,0 +1,208 @@
// MIN Protocol v2.0.
//
// MIN is a lightweight reliable protocol for exchanging information from a microcontroller (MCU) to a host.
// It is designed to run on an 8-bit MCU but also scale up to more powerful devices. A typical use case is to
// send data from a UART on a small MCU over a UART-USB converter plugged into a PC host. A Python implementation
// of host code is provided (or this code could be compiled for a PC).
//
// MIN supports frames of 0-255 bytes (with a lower limit selectable at compile time to reduce RAM). MIN frames
// have identifier values between 0 and 63.
//
// An optional transport layer T-MIN can be compiled in. This provides sliding window reliable transmission of frames.
//
// Compile options:
//
// - Define NO_TRANSPORT_PROTOCOL to remove the code and other overheads of dealing with transport frames. Any
// transport frames sent from the other side are dropped.
//
// - Define MAX_PAYLOAD if the size of the frames is to be limited. This is particularly useful with the transport
// protocol where a deep FIFO is wanted but not for large frames.
//
// The API is as follows:
//
// - min_init_context()
// A MIN context is a structure allocated by the programmer that stores details of the protocol. This permits
// the code to be reentrant and multiple serial ports to be used. The port parameter is used in a callback to
// allow the programmer's serial port drivers to place bytes in the right port. In a typical scenario there will
// be just one context.
//
// - min_send_frame()
// This sends a non-transport frame and will be dropped if the line is noisy.
//
// - min_queue_frame()
// This queues a transport frame which will will be retransmitted until the other side receives it correctly.
//
// - min_poll()
// This passes in received bytes to the context associated with the source. Note that if the transport protocol
// is included then this must be called regularly to operate the transport state machine even if there are no
// incoming bytes.
//
// There are several callbacks: these must be provided by the programmer and are called by the library:
//
// - min_tx_space()
// The programmer's serial drivers must return the number of bytes of space available in the sending buffer.
// This helps cut down on the number of lost frames (and hence improve throughput) if a doomed attempt to transmit a
// frame can be avoided.
//
// - min_tx_byte()
// The programmer's drivers must send a byte on the given port. The implementation of the serial port drivers
// is in the domain of the programmer: they might be interrupt-based, polled, etc.
//
// - min_application_handler()
// This is the callback that provides a MIN frame received on a given port to the application. The programmer
// should then deal with the frame as part of the application.
//
// - min_time_ms()
// This is called to obtain current time in milliseconds. This is used by the MIN transport protocol to drive
// timeouts and retransmits.
#ifndef MIN_H
#define MIN_H
#include <stdint.h>
#include <stdbool.h>
#ifdef ASSERTION_CHECKING
#include <assert.h>
#endif
#ifndef NO_TRANSPORT_PROTOCOL
#define TRANSPORT_PROTOCOL
#endif
#ifndef MAX_PAYLOAD
#define MAX_PAYLOAD (255U)
#endif
// Powers of two for FIFO management. Default is 16 frames in the FIFO, total of 1024 bytes for frame data
#ifndef TRANSPORT_FIFO_SIZE_FRAMES_BITS
#define TRANSPORT_FIFO_SIZE_FRAMES_BITS (4U)
#endif
#ifndef TRANSPORT_FIFO_SIZE_FRAME_DATA_BITS
#define TRANSPORT_FIFO_SIZE_FRAME_DATA_BITS (10U)
#endif
#define TRANSPORT_FIFO_MAX_FRAMES (1U << TRANSPORT_FIFO_SIZE_FRAMES_BITS)
#define TRANSPORT_FIFO_MAX_FRAME_DATA (1U << TRANSPORT_FIFO_SIZE_FRAME_DATA_BITS)
#if (MAX_PAYLOAD > 255)
#error "MIN frame payloads can be no bigger than 255 bytes"
#endif
// Indices into the frames FIFO are uint8_t and so can't have more than 256 frames in a FIFO
#if (TRANSPORT_FIFO_MAX_FRAMES > 256)
#error "Transport FIFO frames cannot exceed 256"
#endif
// Using a 16-bit offset into the frame data FIFO so it has to be addressable within 64Kbytes
#if (TRANSPORT_FIFO_MAX_FRAME_DATA > 65536)
#error "Transport FIFO data allocated cannot exceed 64Kbytes"
#endif
struct crc32_context {
uint32_t crc;
};
#ifdef TRANSPORT_PROTOCOL
struct transport_frame {
uint32_t last_sent_time_ms; // When frame was last sent (used for re-send timeouts)
uint16_t payload_offset; // Where in the ring buffer the payload is
uint8_t payload_len; // How big the payload is
uint8_t min_id; // ID of frame
uint8_t seq; // Sequence number of frame
};
struct transport_fifo {
struct transport_frame frames[TRANSPORT_FIFO_MAX_FRAMES];
uint32_t last_sent_ack_time_ms;
uint32_t last_received_anything_ms;
uint32_t last_received_frame_ms;
uint32_t dropped_frames; // Diagnostic counters
uint32_t spurious_acks;
uint32_t sequence_mismatch_drop;
uint32_t resets_received;
uint16_t n_ring_buffer_bytes; // Number of bytes used in the payload ring buffer
uint16_t n_ring_buffer_bytes_max; // Largest number of bytes ever used
uint16_t ring_buffer_tail_offset; // Tail of the payload ring buffer
uint8_t n_frames; // Number of frames in the FIFO
uint8_t n_frames_max; // Larger number of frames in the FIFO
uint8_t head_idx; // Where frames are taken from in the FIFO
uint8_t tail_idx; // Where new frames are added
uint8_t sn_min; // Sequence numbers for transport protocol
uint8_t sn_max;
uint8_t rn;
};
#endif
struct min_context {
#ifdef TRANSPORT_PROTOCOL
struct transport_fifo transport_fifo; // T-MIN queue of outgoing frames
#endif
uint8_t rx_frame_payload_buf[MAX_PAYLOAD]; // Payload received so far
uint32_t rx_frame_checksum; // Checksum received over the wire
struct crc32_context rx_checksum; // Calculated checksum for receiving frame
struct crc32_context tx_checksum; // Calculated checksum for sending frame
uint8_t rx_header_bytes_seen; // Countdown of header bytes to reset state
uint8_t rx_frame_state; // State of receiver
uint8_t rx_frame_payload_bytes; // Length of payload received so far
uint8_t rx_frame_id_control; // ID and control bit of frame being received
uint8_t rx_frame_seq; // Sequence number of frame being received
uint8_t rx_frame_length; // Length of frame
uint8_t rx_control; // Control byte
uint8_t tx_header_byte_countdown; // Count out the header bytes
uint8_t port; // Number of the port associated with the context
};
#ifdef TRANSPORT_PROTOCOL
// Queue a MIN frame in the transport queue
bool min_queue_frame(struct min_context *self, uint8_t min_id, uint8_t *payload, uint8_t payload_len);
// Determine if MIN has space to queue a transport frame
bool min_queue_has_space_for_frame(struct min_context *self, uint8_t payload_len);
#endif
// Send a non-transport frame MIN frame
void min_send_frame(struct min_context *self, uint8_t min_id, uint8_t *payload, uint8_t payload_len);
// Must be regularly called, with the received bytes since the last call.
// NB: if the transport protocol is being used then even if there are no bytes
// this call must still be made in order to drive the state machine for retransmits.
void min_poll(struct min_context *self, uint8_t *buf, uint32_t buf_len);
// Reset the state machine and (optionally) tell the other side that we have done so
void min_transport_reset(struct min_context *self, bool inform_other_side);
// CALLBACK. Handle incoming MIN frame
void min_application_handler(uint8_t min_id, uint8_t *min_payload, uint8_t len_payload, uint8_t port);
#ifdef TRANSPORT_PROTOCOL
// CALLBACK. Must return current time in milliseconds.
// Typically a tick timer interrupt will increment a 32-bit variable every 1ms (e.g. SysTick on Cortex M ARM devices).
uint32_t min_time_ms(void);
#endif
// CALLBACK. Must return current buffer space in the given port. Used to check that a frame can be
// queued.
uint16_t min_tx_space(uint8_t port);
// CALLBACK. Send a byte on the given line.
void min_tx_byte(uint8_t port, uint8_t byte);
// CALLBACK. Indcates when frame transmission is finished; useful for buffering bytes into a single serial call.
void min_tx_start(uint8_t port);
void min_tx_finished(uint8_t port);
// Initialize a MIN context ready for receiving bytes from a serial link
// (Can have multiple MIN contexts)
void min_init_context(struct min_context *self, uint8_t port);
#ifdef MIN_DEBUG_PRINTING
// Debug print
void min_debug_print(const char *msg, ...);
#else
#define min_debug_print(...)
#endif
#endif //MIN_H

View File

@ -1,25 +1,47 @@
# Massive Knob
Control audio devices using physical knobs.
Control audio devices using physical knobs. And more.
Inspired by [an article on Prusa's blog](https://blog.prusaprinters.org/3d-print-an-oversized-media-control-volume-knob-arduino-basics_30184/), this project has a slightly different set of goals:
Inspired by [an article on Prusa's blog](https://blog.prusaprinters.org/3d-print-an-oversized-media-control-volume-knob-arduino-basics_30184/), this project has a slightly different set of goals. The original requirements were:
**Must have**
1. ✔ Control multiple audio devices, one set of physical controls per device
2. ✔ Volume is set to an absolute value (potentiometer instead of a rotary encoder)
1. Control volume using a potentiometer (fixed position) instead of a rotary encoder (endless rotation)
2. Control specific audio devices, not the current default device
3. Provide means of switching the default device by pressing a button
Because of the second requirement, a simple media keys HID device does not suffice and extra software is required on the desktop.
Because of these requirements, a simple media keys HID device does not suffice and extra software is required on the desktop. This opens up a range of possibilities.
## Features
- 🔊 Set the volume for specific devices / send the current volume to an analog output
- 🔇 Mute / unmute specific devices / send the muted state to a digital output (*e.g. LED*)
- 🎧 Set the default device / set a digital output based on the default device
- 💬 Optional OSD (On-Screen Display)
- 🔌 VoiceMeeter (Standard, Banana & Potato) plugin to execute macros or read the current state
Massive Knob is basically a host for plugins. A plugin can implement a device or actions which either process signals from the device to perform an action (for example, change the volume when a knob is turned) or send signals to the device based on the system state (for example, light up an LED to indicate the default device).
### Devices
Devices can provide the following inputs and outputs, up to 255 for each type (unless you're Look Mum No Computer I assume this will be enough):
1. Analog input (*e.g. a potentiometer*)
2. Digital input (*e.g. a button or switch*)
3. Analog output (*e.g. a PWM output, though not yet supported by the reference Arduino implementation*)
4. Digital output (*e.g. an LED*)
#### Serial
Connects to a compatible device on a Serial port, probably a USB device like an Arduino. The device must implement the Massive Knob protocol which uses the [MIN protocol](https://github.com/min-protocol/min) to send and receive frames. An Arduino Sketch is included with this repository which can be customized to suit your hardware layout.
#### Emulator
Useful for development, this one emulates an actual device. The number of inputs and outputs are configurable, a popup allows changing the inputs and shows the state of the outputs.
**Nice to have**
1. Physical buttons to switch the active device
- by changing the Windows default output device
- by running a VoiceMeeter macro
2. Corresponding LEDs to indicate the currently active device
3. OSD
4. API / plugins to use extra knobs and buttons for other purposes
## Developing
The hardware side uses an Arduino sketch to communicate the hardware state over the serial port.
The Windows software is written in C# using .NET Framework 4.7.2 and Visual Studio 2019.
The Windows software is written in C# using .NET Framework 4.7.2 and Visual Studio 2019.
Refer to the bundled plugins for examples.
Some icons courtesy of https://feathericons.com/

41
Windows/BuildRelease.ps1 Normal file
View File

@ -0,0 +1,41 @@
# Run this script from the Developer PowerShell found in Visual Studio 2019
# or the start menu to get the correct msbuild version on the path
#
# GitVersion is also required and must be available on the path
# Inno Setup 5 is used to compile the setup, it's path is specified below
#
$innoSetupCompiler = "C:\Program Files (x86)\Inno Setup 5\ISCC.exe"
$versionJson = & GitVersion | Out-String
try
{
$version = ConvertFrom-Json $versionJson
}
catch
{
Write-Host "Error while parsing GitVersion output: $($_.Exception.Message)" -ForegroundColor Red
Write-Host $versionJson -ForegroundColor Gray
exit 1
}
Write-Host "GitVersion: $($version.LegacySemVer)"
$env:BUILD_VERSION = $version.LegacySemVer
& msbuild MassiveKnob.sln /t:Clean /t:Build /p:Configuration=Release
if (!$?)
{
Write-Host "MSBuild failed, aborting..." -ForegroundColor Red
exit 1
}
& $innoSetupCompiler "Setup\MassiveKnobSetup.iss"
if (!$?)
{
Write-Host "Inno Setup failed, aborting..." -ForegroundColor Red
exit 1
}

View File

@ -1,69 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace MassiveKnob.Hardware
{
public abstract class AbstractMassiveKnobHardware : IMassiveKnobHardware
{
protected ObserverProxy Observers = new ObserverProxy();
public void AttachObserver(IMassiveKnobHardwareObserver observer)
{
Observers.AttachObserver(observer);
}
public void DetachObserver(IMassiveKnobHardwareObserver observer)
{
Observers.DetachObserver(observer);
}
public abstract Task TryConnect();
public abstract Task Disconnect();
public class ObserverProxy : IMassiveKnobHardwareObserver
{
private readonly List<IMassiveKnobHardwareObserver> observers = new List<IMassiveKnobHardwareObserver>();
public void AttachObserver(IMassiveKnobHardwareObserver observer)
{
observers.Add(observer);
}
public void DetachObserver(IMassiveKnobHardwareObserver observer)
{
observers.Remove(observer);
}
public void Connecting()
{
foreach (var observer in observers)
observer.Connecting();
}
public void Connected(int knobCount)
{
foreach (var observer in observers)
observer.Connected(knobCount);
}
public void Disconnected()
{
foreach (var observer in observers)
observer.Disconnected();
}
public void VolumeChanged(int knob, int volume)
{
foreach (var observer in observers)
observer.VolumeChanged(knob, volume);
}
}
}
}

View File

@ -1,82 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudioSwitcher.AudioApi;
using AudioSwitcher.AudioApi.CoreAudio;
namespace MassiveKnob.Hardware
{
public class CoreAudioDeviceManager : IAudioDeviceManager
{
private readonly Lazy<CoreAudioController> audioController = new Lazy<CoreAudioController>();
private List<IAudioDevice> devices;
public void Dispose()
{
if (audioController.IsValueCreated)
audioController.Value.Dispose();
}
public async Task<IEnumerable<IAudioDevice>> GetDevices()
{
return devices ?? (devices = (await audioController.Value.GetPlaybackDevicesAsync())
.Select(device => new AudioDevice(device) as IAudioDevice)
.ToList());
}
public Task<IAudioDevice> GetDeviceById(Guid deviceId)
{
return Task.FromResult(devices?.FirstOrDefault(device => device.Id == deviceId));
}
private class AudioDevice : IAudioDevice
{
private readonly IDevice device;
public Guid Id { get; }
public string DisplayName { get; }
public AudioDevice(IDevice device)
{
this.device = device;
Id = device.Id;
string displayFormat;
if ((device.State & DeviceState.Disabled) != 0)
displayFormat = Strings.DeviceDisplayNameDisabled;
else if ((device.State & DeviceState.Unplugged) != 0)
displayFormat = Strings.DeviceDisplayNameUnplugged;
else if ((device.State & DeviceState.NotPresent) != 0)
displayFormat = Strings.DeviceDisplayNameNotPresent;
else if ((device.State & DeviceState.Active) == 0)
displayFormat = Strings.DeviceDisplayNameInactive;
else
displayFormat = Strings.DeviceDisplayNameActive;
DisplayName = string.Format(displayFormat, device.FullName);
}
public Task SetVolume(int volume)
{
return device.SetVolumeAsync(volume);
}
}
}
public class CoreAudioDeviceManagerFactory : IAudioDeviceManagerFactory
{
public IAudioDeviceManager Create()
{
return new CoreAudioDeviceManager();
}
}
}

View File

@ -1,27 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace MassiveKnob.Hardware
{
public interface IAudioDevice
{
Guid Id { get; }
string DisplayName { get; }
Task SetVolume(int volume);
}
public interface IAudioDeviceManager : IDisposable
{
Task<IEnumerable<IAudioDevice>> GetDevices();
Task<IAudioDevice> GetDeviceById(Guid deviceId);
}
public interface IAudioDeviceManagerFactory
{
IAudioDeviceManager Create();
}
}

View File

@ -1,31 +0,0 @@
using System.Threading.Tasks;
namespace MassiveKnob.Hardware
{
public interface IMassiveKnobHardwareObserver
{
void Connecting();
void Connected(int knobCount);
void Disconnected();
void VolumeChanged(int knob, int volume);
// void ButtonPress(int index); -- for switching the active device, TBD
}
public interface IMassiveKnobHardware
{
void AttachObserver(IMassiveKnobHardwareObserver observer);
void DetachObserver(IMassiveKnobHardwareObserver observer);
Task TryConnect();
Task Disconnect();
// Task SetActiveKnob(int knob); -- for providing LED feedback when switching the active device, TBD
}
public interface IMassiveKnobHardwareFactory
{
IMassiveKnobHardware Create(string portName);
}
}

View File

@ -1,71 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace MassiveKnob.Hardware
{
public class MockMassiveKnobHardware : AbstractMassiveKnobHardware
{
private readonly int knobCount;
private readonly TimeSpan volumeChangeInterval;
private readonly int maxVolume;
private Timer changeVolumeTimer;
private readonly Random random = new Random();
public MockMassiveKnobHardware(int knobCount, TimeSpan volumeChangeInterval, int maxVolume)
{
this.knobCount = knobCount;
this.volumeChangeInterval = volumeChangeInterval;
this.maxVolume = maxVolume;
}
public override async Task TryConnect()
{
if (changeVolumeTimer != null)
return;
await Task.Delay(2000);
Observers.Connected(knobCount);
changeVolumeTimer = new Timer(
state =>
{
Observers.VolumeChanged(random.Next(0, knobCount), random.Next(0, maxVolume));
},
null,
volumeChangeInterval,
volumeChangeInterval);
}
public override Task Disconnect()
{
changeVolumeTimer?.Dispose();
return Task.CompletedTask;
}
}
// ReSharper disable once UnusedMember.Global - for testing purposes only
public class MockMassiveKnobHardwareFactory : IMassiveKnobHardwareFactory
{
private readonly int knobCount;
private readonly TimeSpan volumeChangeInterval;
private readonly int maxVolume;
public MockMassiveKnobHardwareFactory(int knobCount, TimeSpan volumeChangeInterval, int maxVolume)
{
this.knobCount = knobCount;
this.volumeChangeInterval = volumeChangeInterval;
this.maxVolume = maxVolume;
}
public IMassiveKnobHardware Create(string portName)
{
return new MockMassiveKnobHardware(knobCount, volumeChangeInterval, maxVolume);
}
}
}

View File

@ -1,180 +0,0 @@
using System.Diagnostics;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
namespace MassiveKnob.Hardware
{
public class SerialMassiveKnobHardware : AbstractMassiveKnobHardware
{
private readonly string portName;
private Thread workerThread;
private readonly CancellationTokenSource workerThreadCancellation = new CancellationTokenSource();
private readonly TaskCompletionSource<bool> workerThreadCompleted = new TaskCompletionSource<bool>();
public SerialMassiveKnobHardware(string portName)
{
this.portName = portName;
}
public override async Task TryConnect()
{
if (workerThread != null)
await Disconnect();
workerThread = new Thread(RunWorker)
{
Name = "SerialMassiveKnobHardware Worker"
};
workerThread.Start();
}
public override async Task Disconnect()
{
workerThreadCancellation.Cancel();
await workerThreadCompleted.Task;
workerThread = null;
}
private void RunWorker()
{
Observers.Connecting();
while (!workerThreadCancellation.IsCancellationRequested)
{
SerialPort serialPort = null;
void SafeCloseSerialPort()
{
try
{
serialPort?.Dispose();
}
catch
{
// ignroed
}
serialPort = null;
Observers.Disconnected();
Observers.Connecting();
}
var knobCount = 0;
while (serialPort == null && !workerThreadCancellation.IsCancellationRequested)
{
try
{
serialPort = new SerialPort(portName, 115200);
serialPort.Open();
// Send handshake
serialPort.Write(new[] { 'H', 'M', 'K', 'B' }, 0, 4);
// Wait for reply
serialPort.ReadTimeout = 1000;
var response = serialPort.ReadByte();
if ((char) response == 'H')
{
knobCount = serialPort.ReadByte();
if (knobCount > -1)
break;
}
SafeCloseSerialPort();
Thread.Sleep(500);
}
catch
{
SafeCloseSerialPort();
Thread.Sleep(500);
}
}
if (workerThreadCancellation.IsCancellationRequested)
{
SafeCloseSerialPort();
break;
}
var processingMessage = false;
Debug.Assert(serialPort != null, nameof(serialPort) + " != null");
serialPort.DataReceived += (sender, args) =>
{
if (args.EventType != SerialData.Chars || processingMessage)
return;
var senderPort = (SerialPort) sender;
processingMessage = true;
try
{
var message = (char) senderPort.ReadByte();
ProcessMessage(senderPort, message);
}
finally
{
processingMessage = false;
}
};
Observers.Connected(knobCount);
try
{
// This is where sending data to the hardware would be implemented
while (serialPort.IsOpen && !workerThreadCancellation.IsCancellationRequested)
{
Thread.Sleep(10);
}
}
catch
{
// ignored
}
Observers.Disconnected();
SafeCloseSerialPort();
if (!workerThreadCancellation.IsCancellationRequested)
Thread.Sleep(500);
}
workerThreadCompleted.TrySetResult(true);
}
private void ProcessMessage(SerialPort serialPort, char message)
{
switch (message)
{
case 'V':
var knobIndex = (byte)serialPort.ReadByte();
var volume = (byte)serialPort.ReadByte();
if (knobIndex < 255 && volume <= 100)
Observers.VolumeChanged(knobIndex, volume);
break;
}
}
}
public class SerialMassiveKnobHardwareFactory : IMassiveKnobHardwareFactory
{
public IMassiveKnobHardware Create(string portName)
{
return new SerialMassiveKnobHardware(portName);
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

View File

@ -0,0 +1,12 @@
using System;
namespace MassiveKnob.Plugin.CoreAudio.Base
{
public class BaseDeviceSettings
{
public Guid? DeviceId { get; set; }
// TODO (nice to have) more options, like positioning and style
public bool OSD { get; set; } = true;
}
}

View File

@ -0,0 +1,19 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.Base.BaseDeviceSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:base="clr-namespace:MassiveKnob.Plugin.CoreAudio.Base"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.CoreAudio"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance base:BaseDeviceSettingsViewModel}">
<StackPanel Orientation="Vertical">
<TextBlock Text="{x:Static coreAudio:Strings.SettingPlaybackDevice}" />
<ComboBox Margin="0,4,0,0" ItemsSource="{Binding PlaybackDevices}" SelectedItem="{Binding SelectedDevice}" DisplayMemberPath="DisplayName" />
<CheckBox Margin="0,8,0,0" IsChecked="{Binding OSD}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingOSD}" />
</CheckBox>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,13 @@
namespace MassiveKnob.Plugin.CoreAudio.Base
{
/// <summary>
/// Interaction logic for BaseDeviceSettingsView.xaml
/// </summary>
public partial class BaseDeviceSettingsView
{
public BaseDeviceSettingsView()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows;
using AudioSwitcher.AudioApi;
namespace MassiveKnob.Plugin.CoreAudio.Base
{
public class BaseDeviceSettingsViewModel<T> : BaseDeviceSettingsViewModel where T : BaseDeviceSettings
{
protected new T Settings => (T)base.Settings;
public BaseDeviceSettingsViewModel(T settings) : base(settings)
{
}
}
public class BaseDeviceSettingsViewModel : INotifyPropertyChanged
{
protected readonly BaseDeviceSettings Settings;
public event PropertyChangedEventHandler PropertyChanged;
private IList<PlaybackDeviceViewModel> playbackDevices;
private PlaybackDeviceViewModel selectedDevice;
// ReSharper disable UnusedMember.Global - used by WPF Binding
public IList<PlaybackDeviceViewModel> PlaybackDevices
{
get => playbackDevices;
set
{
playbackDevices = value;
OnPropertyChanged();
}
}
public PlaybackDeviceViewModel SelectedDevice
{
get => selectedDevice;
set
{
if (value == selectedDevice)
return;
selectedDevice = value;
Settings.DeviceId = selectedDevice?.Id;
OnPropertyChanged();
}
}
public bool OSD
{
get => Settings.OSD;
set
{
if (value == Settings.OSD)
return;
Settings.OSD = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
public BaseDeviceSettingsViewModel(BaseDeviceSettings settings)
{
Settings = settings;
Task.Run(async () =>
{
var coreAudioController = CoreAudioControllerInstance.Acquire();
var devices = await coreAudioController.GetPlaybackDevicesAsync();
var deviceViewModels = devices
.OrderBy(d => d.FullName)
.Select(PlaybackDeviceViewModel.FromDevice)
.ToList();
Application.Current.Dispatcher.Invoke(() =>
{
PlaybackDevices = deviceViewModels;
SelectedDevice = deviceViewModels.SingleOrDefault(d => d.Id == settings.DeviceId);
});
});
}
public virtual bool IsSettingsProperty(string propertyName)
{
return propertyName != nameof(PlaybackDevices);
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public class PlaybackDeviceViewModel
{
public Guid Id { get; private set; }
public string DisplayName { get; private set; }
public static PlaybackDeviceViewModel FromDevice(IDevice device)
{
string displayFormat;
if ((device.State & DeviceState.Disabled) != 0)
displayFormat = Strings.DeviceDisplayNameDisabled;
else if ((device.State & DeviceState.Unplugged) != 0)
displayFormat = Strings.DeviceDisplayNameUnplugged;
else if ((device.State & DeviceState.NotPresent) != 0)
displayFormat = Strings.DeviceDisplayNameNotPresent;
else if ((device.State & DeviceState.Active) == 0)
displayFormat = Strings.DeviceDisplayNameInactive;
else
displayFormat = Strings.DeviceDisplayNameActive;
return new PlaybackDeviceViewModel
{
Id = device.Id,
DisplayName = string.Format(displayFormat, device.FullName)
};
}
}
}

View File

@ -0,0 +1,16 @@
using System;
using AudioSwitcher.AudioApi.CoreAudio;
namespace MassiveKnob.Plugin.CoreAudio
{
public static class CoreAudioControllerInstance
{
private static readonly Lazy<CoreAudioController> Instance = new Lazy<CoreAudioController>();
public static CoreAudioController Acquire()
{
return Instance.Value;
}
}
}

View File

@ -0,0 +1,101 @@
using System;
using System.Windows.Controls;
using AudioSwitcher.AudioApi;
using MassiveKnob.Plugin.CoreAudio.OSD;
using Microsoft.Extensions.Logging;
namespace MassiveKnob.Plugin.CoreAudio.GetDefault
{
public class DeviceGetDefaultAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("3c427e28-493f-489f-abb3-1a7ef23ca6c9");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.OutputDigital;
public string Name { get; } = Strings.GetDefaultName;
public string Description { get; } = Strings.GetDefaultDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobActionInstance
{
private IMassiveKnobActionContext actionContext;
private DeviceGetDefaultActionSettings settings;
private IDevice playbackDevice;
private IDisposable deviceChanged;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<DeviceGetDefaultActionSettings>();
ApplySettings();
}
public void Dispose()
{
deviceChanged?.Dispose();
}
private void ApplySettings()
{
if (playbackDevice != null && playbackDevice.Id == settings.DeviceId)
return;
var coreAudioController = CoreAudioControllerInstance.Acquire();
playbackDevice = settings.DeviceId.HasValue ? coreAudioController.GetDevice(settings.DeviceId.Value) : null;
deviceChanged?.Dispose();
deviceChanged = playbackDevice?.PropertyChanged.Subscribe(PropertyChanged);
CheckActive();
}
public UserControl CreateSettingsControl()
{
var viewModel = new DeviceGetDefaultActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new DeviceGetDefaultActionSettingsView(viewModel);
}
private void PropertyChanged(DevicePropertyChangedArgs args)
{
if (args.ChangedType != DeviceChangedType.DefaultChanged)
return;
CheckActive();
// TODO default OSD
//if (settings.OSD)
//OSDManager.Show(args.Device);
}
private void CheckActive()
{
if (playbackDevice == null)
return;
var isDefault = (settings.Playback && playbackDevice.IsDefaultDevice) ||
(settings.Communications && playbackDevice.IsDefaultCommunicationsDevice);
actionContext.SetDigitalOutput(settings.Inverted ? !isDefault : isDefault);
}
}
}
}

View File

@ -0,0 +1,12 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.GetDefault
{
public class DeviceGetDefaultActionSettings : BaseDeviceSettings
{
public bool Playback { get; set; } = true;
public bool Communications { get; set; } = true;
public bool Inverted { get; set; }
}
}

View File

@ -0,0 +1,28 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.GetDefault.DeviceGetDefaultActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:getDefault="clr-namespace:MassiveKnob.Plugin.CoreAudio.GetDefault"
xmlns:base="clr-namespace:MassiveKnob.Plugin.CoreAudio.Base"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.CoreAudio"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance getDefault:DeviceGetDefaultActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseDeviceSettingsView />
<TextBlock Margin="0,24,0,0" Text="{x:Static coreAudio:Strings.SettingGetDefaultWhen}" />
<CheckBox Margin="0,8,0,0" IsChecked="{Binding Playback}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingGetDefaultPlayback}" />
</CheckBox>
<CheckBox Margin="0,8,0,0" IsChecked="{Binding Communications}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingGetDefaultCommunications}" />
</CheckBox>
<CheckBox Margin="0,24,0,0" IsChecked="{Binding Inverted}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingGetDefaultInverted}" />
</CheckBox>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.CoreAudio.GetDefault
{
/// <summary>
/// Interaction logic for DeviceGetDefaultActionSettingsView.xaml
/// </summary>
public partial class DeviceGetDefaultActionSettingsView
{
public DeviceGetDefaultActionSettingsView(DeviceGetDefaultActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,54 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.GetDefault
{
public class DeviceGetDefaultActionSettingsViewModel : BaseDeviceSettingsViewModel<DeviceGetDefaultActionSettings>
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public bool Playback
{
get => Settings.Playback;
set
{
if (value == Settings.Playback)
return;
Settings.Playback = value;
OnPropertyChanged();
}
}
public bool Communications
{
get => Settings.Communications;
set
{
if (value == Settings.Communications)
return;
Settings.Communications = value;
OnPropertyChanged();
}
}
public bool Inverted
{
get => Settings.Inverted;
set
{
if (value == Settings.Inverted)
return;
Settings.Inverted = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
// ReSharper disable once SuggestBaseTypeForParameter - by design
public DeviceGetDefaultActionSettingsViewModel(DeviceGetDefaultActionSettings settings) : base(settings)
{
}
}
}

View File

@ -0,0 +1,86 @@
using System;
using System.Windows.Controls;
using AudioSwitcher.AudioApi;
using MassiveKnob.Plugin.CoreAudio.OSD;
using Microsoft.Extensions.Logging;
namespace MassiveKnob.Plugin.CoreAudio.GetMuted
{
public class DeviceGetMutedAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("86646ca7-f472-4c5a-8d0f-7e5d2d162ab9");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.OutputDigital;
public string Name { get; } = Strings.GetMutedName;
public string Description { get; } = Strings.GetMutedDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobActionInstance
{
private IMassiveKnobActionContext actionContext;
private DeviceGetMutedActionSettings settings;
private IDevice playbackDevice;
private IDisposable deviceChanged;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<DeviceGetMutedActionSettings>();
ApplySettings();
}
public void Dispose()
{
deviceChanged?.Dispose();
}
private void ApplySettings()
{
if (playbackDevice != null && playbackDevice.Id == settings.DeviceId)
return;
var coreAudioController = CoreAudioControllerInstance.Acquire();
playbackDevice = settings.DeviceId.HasValue ? coreAudioController.GetDevice(settings.DeviceId.Value) : null;
deviceChanged?.Dispose();
deviceChanged = playbackDevice?.MuteChanged.Subscribe(MuteChanged);
if (playbackDevice != null)
actionContext.SetDigitalOutput(settings.Inverted ? !playbackDevice.IsMuted : playbackDevice.IsMuted);
}
public UserControl CreateSettingsControl()
{
var viewModel = new DeviceGetMutedActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new DeviceGetMutedActionSettingsView(viewModel);
}
private void MuteChanged(DeviceMuteChangedArgs args)
{
actionContext.SetDigitalOutput(settings.Inverted ? !args.IsMuted : args.IsMuted);
if (settings.OSD)
OSDManager.Show(args.Device);
}
}
}
}

View File

@ -0,0 +1,9 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.GetMuted
{
public class DeviceGetMutedActionSettings : BaseDeviceSettings
{
public bool Inverted { get; set; }
}
}

View File

@ -0,0 +1,19 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.GetMuted.DeviceGetMutedActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:getMuted="clr-namespace:MassiveKnob.Plugin.CoreAudio.GetMuted"
xmlns:base="clr-namespace:MassiveKnob.Plugin.CoreAudio.Base"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.CoreAudio"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance getMuted:DeviceGetMutedActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseDeviceSettingsView />
<CheckBox Margin="0,8,0,0" IsChecked="{Binding Inverted}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingGetMutedInverted}" />
</CheckBox>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.CoreAudio.GetMuted
{
/// <summary>
/// Interaction logic for DeviceGetMutedActionSettingsView.xaml
/// </summary>
public partial class DeviceGetMutedActionSettingsView
{
public DeviceGetMutedActionSettingsView(DeviceGetMutedActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,28 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.GetMuted
{
public class DeviceGetMutedActionSettingsViewModel : BaseDeviceSettingsViewModel<DeviceGetMutedActionSettings>
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public bool Inverted
{
get => Settings.Inverted;
set
{
if (value == Settings.Inverted)
return;
Settings.Inverted = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
// ReSharper disable once SuggestBaseTypeForParameter - by design
public DeviceGetMutedActionSettingsViewModel(DeviceGetMutedActionSettings settings) : base(settings)
{
}
}
}

View File

@ -0,0 +1,86 @@
using System;
using System.Windows.Controls;
using AudioSwitcher.AudioApi;
using MassiveKnob.Plugin.CoreAudio.OSD;
using Microsoft.Extensions.Logging;
namespace MassiveKnob.Plugin.CoreAudio.GetVolume
{
public class DeviceGetVolumeAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("6ebf91af-8240-4a75-9729-c6a1eb60dcba");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.OutputAnalog;
public string Name { get; } = Strings.GetVolumeName;
public string Description { get; } = Strings.GetVolumeDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobActionInstance
{
private IMassiveKnobActionContext actionContext;
private DeviceGetVolumeActionSettings settings;
private IDevice playbackDevice;
private IDisposable deviceChanged;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<DeviceGetVolumeActionSettings>();
ApplySettings();
}
public void Dispose()
{
deviceChanged?.Dispose();
}
private void ApplySettings()
{
if (playbackDevice != null && playbackDevice.Id == settings.DeviceId)
return;
var coreAudioController = CoreAudioControllerInstance.Acquire();
playbackDevice = settings.DeviceId.HasValue ? coreAudioController.GetDevice(settings.DeviceId.Value) : null;
deviceChanged?.Dispose();
deviceChanged = playbackDevice?.VolumeChanged.Subscribe(VolumeChanged);
if (playbackDevice != null)
actionContext.SetAnalogOutput((byte)playbackDevice.Volume);
}
public UserControl CreateSettingsControl()
{
var viewModel = new DeviceGetVolumeActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new DeviceGetVolumeActionSettingsView(viewModel);
}
private void VolumeChanged(DeviceVolumeChangedArgs args)
{
actionContext.SetAnalogOutput((byte)args.Volume);
if (settings.OSD)
OSDManager.Show(args.Device);
}
}
}
}

View File

@ -0,0 +1,8 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.GetVolume
{
public class DeviceGetVolumeActionSettings : BaseDeviceSettings
{
}
}

View File

@ -0,0 +1,14 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.GetVolume.DeviceGetVolumeActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:getVolume="clr-namespace:MassiveKnob.Plugin.CoreAudio.GetVolume"
xmlns:base="clr-namespace:MassiveKnob.Plugin.CoreAudio.Base"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance getVolume:DeviceGetVolumeActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseDeviceSettingsView />
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.CoreAudio.GetVolume
{
/// <summary>
/// Interaction logic for DeviceGetVolumeActionSettingsView.xaml
/// </summary>
public partial class DeviceGetVolumeActionSettingsView
{
public DeviceGetVolumeActionSettingsView(DeviceGetVolumeActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,12 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.GetVolume
{
public class DeviceGetVolumeActionSettingsViewModel : BaseDeviceSettingsViewModel<DeviceGetVolumeActionSettings>
{
// ReSharper disable once SuggestBaseTypeForParameter - by design
public DeviceGetVolumeActionSettingsViewModel(DeviceGetVolumeActionSettings settings) : base(settings)
{
}
}
}

View File

@ -0,0 +1,175 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{5BD5E2F2-9923-4F74-AC69-ACDA0B847937}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>MassiveKnob.Plugin.CoreAudio</RootNamespace>
<AssemblyName>MassiveKnob.Plugin.CoreAudio</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Xaml" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
<Reference Include="WindowsBase" />
</ItemGroup>
<ItemGroup>
<Compile Include="Base\BaseDeviceSettingsView.xaml.cs">
<DependentUpon>BaseDeviceSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="GetDefault\DeviceGetDefaultAction.cs" />
<Compile Include="GetDefault\DeviceGetDefaultActionSettings.cs" />
<Compile Include="GetDefault\DeviceGetDefaultActionSettingsView.xaml.cs">
<DependentUpon>DeviceGetDefaultActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="GetDefault\DeviceGetDefaultActionSettingsViewModel.cs" />
<Compile Include="GetMuted\DeviceGetMutedAction.cs" />
<Compile Include="GetMuted\DeviceGetMutedActionSettings.cs" />
<Compile Include="GetMuted\DeviceGetMutedActionSettingsView.xaml.cs">
<DependentUpon>DeviceGetMutedActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="GetMuted\DeviceGetMutedActionSettingsViewModel.cs" />
<Compile Include="GetVolume\DeviceGetVolumeAction.cs" />
<Compile Include="OSD\OSDWindow.xaml.cs">
<DependentUpon>OSDWindow.xaml</DependentUpon>
</Compile>
<Compile Include="OSD\OSDManager.cs" />
<Compile Include="OSD\OSDWindowViewModel.cs" />
<Compile Include="SetDefault\DeviceSetDefaultAction.cs" />
<Compile Include="SetDefault\DeviceSetDefaultActionSettings.cs" />
<Compile Include="SetDefault\DeviceSetDefaultActionSettingsView.xaml.cs">
<DependentUpon>DeviceSetDefaultActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="SetDefault\DeviceSetDefaultActionSettingsViewModel.cs" />
<Compile Include="SetMuted\DeviceSetMutedAction.cs" />
<Compile Include="SetMuted\DeviceSetMutedActionSettings.cs" />
<Compile Include="SetMuted\DeviceSetMutedActionSettingsView.xaml.cs">
<DependentUpon>DeviceSetMutedActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="SetMuted\DeviceSetMutedActionSettingsViewModel.cs" />
<Compile Include="SetVolume\DeviceSetVolumeAction.cs" />
<Compile Include="CoreAudioControllerInstance.cs" />
<Compile Include="GetVolume\DeviceGetVolumeActionSettingsViewModel.cs" />
<Compile Include="GetVolume\DeviceGetVolumeActionSettingsView.xaml.cs">
<DependentUpon>DeviceGetVolumeActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="MassiveKnobCoreAudioPlugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="GetVolume\DeviceGetVolumeActionSettings.cs" />
<Compile Include="SetVolume\DeviceSetVolumeActionSettings.cs" />
<Compile Include="SetVolume\DeviceSetVolumeActionSettingsView.xaml.cs">
<DependentUpon>DeviceSetVolumeActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="Base\BaseDeviceSettings.cs" />
<Compile Include="Base\BaseDeviceSettingsViewModel.cs" />
<Compile Include="SetVolume\DeviceSetVolumeActionSettingsViewModel.cs" />
<Compile Include="Strings.Designer.cs">
<DependentUpon>Strings.resx</DependentUpon>
<AutoGen>True</AutoGen>
<DesignTime>True</DesignTime>
</Compile>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MassiveKnob.Plugin\MassiveKnob.Plugin.csproj">
<Project>{a1298be4-1d23-416c-8c56-fc9264487a95}</Project>
<Name>MassiveKnob.Plugin</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="AudioSwitcher.AudioApi.CoreAudio">
<Version>4.0.0-alpha5</Version>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions">
<Version>5.0.0</Version>
</PackageReference>
<PackageReference Include="System.Reactive">
<Version>5.0.0</Version>
</PackageReference>
<PackageReference Include="System.Threading.Tasks.Extensions">
<Version>4.5.4</Version>
</PackageReference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Strings.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Strings.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Page Include="Base\BaseDeviceSettingsView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="GetDefault\DeviceGetDefaultActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="GetMuted\DeviceGetMutedActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="GetVolume\DeviceGetVolumeActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="OSD\OSDWindow.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="OSD\SpeakerIcon.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="SetDefault\DeviceSetDefaultActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="SetMuted\DeviceSetMutedActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="SetVolume\DeviceSetVolumeActionSettingsView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<None Include="MassiveKnobPlugin.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MassiveKnob.Plugin.CoreAudio.GetDefault;
using MassiveKnob.Plugin.CoreAudio.GetMuted;
using MassiveKnob.Plugin.CoreAudio.GetVolume;
using MassiveKnob.Plugin.CoreAudio.SetDefault;
using MassiveKnob.Plugin.CoreAudio.SetMuted;
using MassiveKnob.Plugin.CoreAudio.SetVolume;
namespace MassiveKnob.Plugin.CoreAudio
{
[MassiveKnobPlugin]
public class MassiveKnobCoreAudioPlugin : IMassiveKnobActionPlugin
{
public Guid PluginId { get; } = new Guid("eaa5d3f8-8f9b-4a4b-8e29-827228d23e95");
public string Name { get; } = Strings.PluginName;
public string Description { get; } = Strings.PluginDescription;
public string Author { get; } = "Mark van Renswoude <mark@x2software.net>";
public string Url { get; } = "https://www.github.com/MvRens/MassiveKnob/";
public IEnumerable<IMassiveKnobAction> Actions { get; } = new IMassiveKnobAction[]
{
new DeviceSetVolumeAction(),
new DeviceGetVolumeAction(),
new DeviceSetMutedAction(),
new DeviceGetMutedAction(),
new DeviceSetDefaultAction(),
new DeviceGetDefaultAction()
};
public MassiveKnobCoreAudioPlugin()
{
// My system suffers from this issue: https://github.com/xenolightning/AudioSwitcher/issues/40
// ...which causes the first call to the CoreAudioController to take up to 10 seconds,
// so initialise it as soon as possible. Bit of a workaround, but eh.
Task.Run(() =>
{
CoreAudioControllerInstance.Acquire();
});
}
}
}

View File

@ -0,0 +1,3 @@
{
"EntryAssembly": "MassiveKnob.Plugin.CoreAudio.dll"
}

View File

@ -0,0 +1,60 @@
using System;
using System.Threading;
using System.Windows;
using AudioSwitcher.AudioApi;
namespace MassiveKnob.Plugin.CoreAudio.OSD
{
public static class OSDManager
{
private const int OSDTimeout = 2500;
private static OSDWindowViewModel windowViewModel;
private static Window window;
private static Timer hideTimer;
public static void Show(IDevice device)
{
Application.Current.Dispatcher.Invoke(() =>
{
if (window == null)
{
windowViewModel = new OSDWindowViewModel();
window = new OSDWindow(windowViewModel);
window.Closed += WindowOnClosed;
hideTimer = new Timer(state =>
{
Hide();
}, null, OSDTimeout, Timeout.Infinite);
}
else
hideTimer.Change(OSDTimeout, Timeout.Infinite);
windowViewModel.SetDevice(device);
window.Show();
});
}
private static void WindowOnClosed(object sender, EventArgs e)
{
hideTimer?.Dispose();
hideTimer = null;
}
private static void Hide()
{
Application.Current?.Dispatcher.Invoke(() =>
{
window?.Close();
window = null;
windowViewModel = null;
hideTimer?.Dispose();
hideTimer = null;
});
}
}
}

View File

@ -0,0 +1,55 @@
<Window x:Class="MassiveKnob.Plugin.CoreAudio.OSD.OSDWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:osd="clr-namespace:MassiveKnob.Plugin.CoreAudio.OSD"
mc:Ignorable="d"
Title="Massive Knob OSD" Height="60" Width="360"
WindowStartupLocation="Manual" WindowStyle="None" AllowsTransparency="True" ShowInTaskbar="False" Topmost="True"
Loaded="OSDWindow_OnLoaded" Closing="OSDWindow_OnClosing"
d:DataContext="{d:DesignInstance osd:OSDWindowViewModel}">
<Window.Triggers>
<EventTrigger RoutedEvent="Window.Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity" From="0" To="1" Duration="0:0:0.250" FillBehavior="HoldEnd" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Window.Triggers>
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="SpeakerIcon.xaml" />
</ResourceDictionary.MergedDictionaries>
<!-- ReSharper disable once Xaml.RedundantResource - used in runtime -->
<Storyboard x:Key="CloseStoryboard" Completed="CloseStoryboard_Completed">
<DoubleAnimation Storyboard.TargetProperty="Opacity" From="1" To="0" Duration="0:0:0.250" FillBehavior="HoldEnd" />
</Storyboard>
<Style TargetType="DockPanel" x:Key="OSDWindow">
<Setter Property="Background" Value="#2d2d30" />
</Style>
<Style TargetType="TextBlock" x:Key="DeviceName">
<Setter Property="Foreground" Value="White" />
<Setter Property="Margin" Value="8,4,8,4" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="TextTrimming" Value="CharacterEllipsis"></Setter>
</Style>
<Style x:Key="SpeakerIconStyle">
<Setter Property="Control.Margin" Value="8,4,8,4" />
</Style>
</ResourceDictionary>
</Window.Resources>
<DockPanel Style="{StaticResource OSDWindow}">
<TextBlock DockPanel.Dock="Top" Text="{Binding DeviceName}" Style="{StaticResource DeviceName}"></TextBlock>
<ContentControl DockPanel.Dock="Left" Content="{StaticResource SpeakerIcon}" Style="{StaticResource SpeakerIconStyle}" />
<Canvas Width="300" Height="20" Margin="8,0,8,0">
<Line X1="0" X2="300" Y1="10" Y2="10" Stroke="#80FFFFFF" StrokeThickness="2" />
<Line X1="{Binding VolumeIndicatorLeft}" X2="{Binding VolumeIndicatorLeft}" Y1="0" Y2="20" Stroke="White" StrokeThickness="2" />
</Canvas>
</DockPanel>
</Window>

View File

@ -0,0 +1,49 @@
using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Forms;
using System.Windows.Media.Animation;
namespace MassiveKnob.Plugin.CoreAudio.OSD
{
/// <summary>
/// Interaction logic for OSDWindow.xaml
/// </summary>
public partial class OSDWindow
{
private bool closeStoryBoardCompleted;
public OSDWindow(OSDWindowViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
private void OSDWindow_OnLoaded(object sender, RoutedEventArgs e)
{
var desktopArea = Screen.PrimaryScreen.WorkingArea;
Left = (desktopArea.Width - Width) / 2;
Top = desktopArea.Bottom - Height - 25;
}
private void OSDWindow_OnClosing(object sender, CancelEventArgs e)
{
if (closeStoryBoardCompleted)
return;
((Storyboard)FindResource("CloseStoryboard")).Begin(this);
e.Cancel = true;
}
private void CloseStoryboard_Completed(object sender, EventArgs e)
{
closeStoryBoardCompleted = true;
Close();
}
}
}

View File

@ -0,0 +1,91 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using AudioSwitcher.AudioApi;
namespace MassiveKnob.Plugin.CoreAudio.OSD
{
public class OSDWindowViewModel : INotifyPropertyChanged
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
private string deviceName;
public string DeviceName
{
get => deviceName;
set
{
if (value == deviceName)
return;
deviceName = value;
OnPropertyChanged();
}
}
private int volume;
public int Volume
{
get => volume;
set
{
if (value == volume)
return;
volume = value;
OnPropertyChanged();
OnDependantPropertyChanged(nameof(VolumeLowVisibility));
OnDependantPropertyChanged(nameof(VolumeMediumVisibility));
OnDependantPropertyChanged(nameof(VolumeHighVisibility));
OnDependantPropertyChanged(nameof(VolumeIndicatorLeft));
}
}
private bool isMuted;
public bool IsMuted
{
get => isMuted;
set
{
if (value == isMuted)
return;
isMuted = value;
OnPropertyChanged();
OnDependantPropertyChanged(nameof(IsMutedVisibility));
OnDependantPropertyChanged(nameof(IsNotMutedVisibility));
}
}
public Visibility IsMutedVisibility => IsMuted ? Visibility.Visible : Visibility.Collapsed;
public Visibility IsNotMutedVisibility => IsMuted ? Visibility.Collapsed : Visibility.Visible;
public Visibility VolumeLowVisibility => Volume > 0 ? Visibility.Visible : Visibility.Collapsed;
public Visibility VolumeMediumVisibility => Volume > 33 ? Visibility.Visible : Visibility.Collapsed;
public Visibility VolumeHighVisibility => Volume > 66 ? Visibility.Visible : Visibility.Collapsed;
public int VolumeIndicatorLeft => Volume * 3;
// ReSharper enable UnusedMember.Global
public void SetDevice(IDevice device)
{
DeviceName = device.FullName;
Volume = (int)device.Volume;
IsMuted = device.IsMuted;
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected virtual void OnDependantPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}

View File

@ -0,0 +1,53 @@
<!--
AI saved to SVG, converted to XAML using Inkscape, then modified manually to provide
interactivity. Be aware of this when overwriting this file.
-->
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Viewbox
xmlns:osd="clr-namespace:MassiveKnob.Plugin.CoreAudio.OSD"
Stretch="Uniform"
x:Key="SpeakerIcon"
d:DataContext="{d:DesignInstance osd:OSDWindowViewModel}">
<Canvas Width="256" Height="256">
<Polygon Visibility="{Binding IsNotMutedVisibility}"
Points=" 133.5,215.101 61.75,168 8.75,168 8.75,88 61.75,88 133.5,40.899 " Name="Speaker_1_" FillRule="NonZero" StrokeThickness="12" Stroke="#FFFFFFFF" StrokeMiterLimit="10" StrokeLineJoin="Round"/>
<Path Visibility="{Binding VolumeLowVisibility}" Name="Low" StrokeThickness="12" Stroke="#FFFFFFFF" StrokeMiterLimit="10" StrokeLineJoin="Round" StrokeStartLineCap="Round" StrokeEndLineCap="Round">
<Path.Data>
<PathGeometry Figures=" M166.806 86c0 0 12.528 15.833 12.528 40.167s-12.528 43.823-12.528 43.823" FillRule="NonZero"/>
</Path.Data>
</Path>
<Path Visibility="{Binding VolumeMediumVisibility}" Name="Medium" StrokeThickness="12" Stroke="#FFFFFFFF" StrokeMiterLimit="10" StrokeLineJoin="Round" StrokeStartLineCap="Round" StrokeEndLineCap="Round">
<Path.Data>
<PathGeometry Figures=" M188.479 57c0 0 21.183 26.769 21.183 67.91c0 41.141-21.183 74.089-21.183 74.089" FillRule="NonZero"/>
</Path.Data>
</Path>
<Path Visibility="{Binding VolumeHighVisibility}" Name="High" StrokeThickness="12" Stroke="#FFFFFFFF" StrokeMiterLimit="10" StrokeLineJoin="Round" StrokeStartLineCap="Round" StrokeEndLineCap="Round">
<Path.Data>
<PathGeometry Figures=" M216.737 35.517c0 0 27.944 35.316 27.944 89.593s-27.944 97.75-27.944 97.75" FillRule="NonZero"/>
</Path.Data>
</Path>
<Path Visibility="{Binding IsMutedVisibility}" Name="path4561" Fill="#FFFFFFFF">
<Path.Data>
<PathGeometry Figures="M160.503 221.101c-1.717 0-3.421-0.732-4.608-2.153L10.395 44.746c-2.125-2.543-1.785-6.327 0.759-8.451 c2.544-2.125 6.328-1.784 8.451 0.759l145.5 174.201c2.124 2.544 1.784 6.327-0.759 8.452 C163.224 220.644 161.859 221.101 160.503 221.101z" FillRule="NonZero"/>
</Path.Data>
</Path>
<Path Visibility="{Binding IsMutedVisibility}" Name="path4563" Fill="#FFFFFFFF">
<Path.Data>
<PathGeometry Figures="M127.5 203.984l-62.458-41C64.064 162.342 62.92 162 61.75 162h-47V94h28.967L33.694 82H8.75c-3.313 0-6 2.687-6 6v80 c0 3.313 2.687 6 6 6h51.207l70.25 46.116c0.997 0.654 2.144 0.984 3.293 0.984c0.979 0 1.958-0.238 2.85-0.72 c1.94-1.048 3.15-3.075 3.15-5.28v-6.423l-12-14.367V203.984z" FillRule="NonZero"/>
</Path.Data>
</Path>
<Path Visibility="{Binding IsMutedVisibility}" Name="path4565" Fill="#FFFFFFFF">
<Path.Data>
<PathGeometry Figures="M127.5 52.016v104.856l12 14.367V40.899c0-2.205-1.21-4.232-3.15-5.28c-1.939-1.047-4.299-0.947-6.143 0.264L63.19 79.877 l7.744 9.271L127.5 52.016z" FillRule="NonZero"/>
</Path.Data>
</Path>
</Canvas>
</Viewbox>
</ResourceDictionary>

View File

@ -0,0 +1,35 @@
using System.Reflection;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MassiveKnob.Plugin.CoreAudio")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("MassiveKnob.Plugin.CoreAudio")]
[assembly: AssemblyCopyright("Copyright © 2021")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("5bd5e2f2-9923-4f74-ac69-acda0b847937")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="256px"
height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve">
<g id="Speaker" display="none">
<polygon id="Speaker_1_" display="inline" fill="none" stroke="#000000" stroke-width="12" stroke-linejoin="round" stroke-miterlimit="10" points="
133.5,215.101 61.75,168 8.75,168 8.75,88 61.75,88 133.5,40.899 "/>
<path id="Low" display="inline" fill="none" stroke="#000000" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="
M166.806,86c0,0,12.528,15.833,12.528,40.167s-12.528,43.823-12.528,43.823"/>
<path id="Medium" display="inline" fill="none" stroke="#000000" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="
M188.479,57c0,0,21.183,26.769,21.183,67.91c0,41.141-21.183,74.089-21.183,74.089"/>
<path id="High" display="inline" fill="none" stroke="#000000" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="
M216.737,35.517c0,0,27.944,35.316,27.944,89.593s-27.944,97.75-27.944,97.75"/>
</g>
<g id="Speaker_outline" display="none">
<path display="inline" d="M133.5,221.101c-1.149,0-2.296-0.33-3.293-0.984L59.957,174H8.75c-3.313,0-6-2.687-6-6V88
c0-3.313,2.687-6,6-6h51.207l70.25-46.116c1.844-1.211,4.203-1.312,6.143-0.264c1.94,1.047,3.15,3.075,3.15,5.28v174.201
c0,2.205-1.21,4.232-3.15,5.28C135.458,220.862,134.479,221.101,133.5,221.101z M14.75,162h47c1.17,0,2.314,0.342,3.292,0.984
l62.458,41V52.016l-62.458,41C64.064,93.658,62.92,94,61.75,94h-47V162z"/>
<path display="inline" d="M166.8,175.99c-1.11,0-2.234-0.309-3.238-0.954c-2.788-1.792-3.595-5.504-1.803-8.291
c0.109-0.173,11.575-18.406,11.575-40.579c0-21.992-11.121-36.301-11.233-36.443c-2.057-2.599-1.616-6.372,0.982-8.428
c2.598-2.057,6.371-1.617,8.428,0.982c0.564,0.713,13.823,17.77,13.823,43.89c0,25.799-12.931,46.21-13.481,47.067
C170.706,175.018,168.773,175.99,166.8,175.99z"/>
<path display="inline" d="M188.473,205c-1.111,0-2.234-0.309-3.239-0.954c-2.787-1.792-3.594-5.504-1.802-8.292
c0.199-0.311,20.229-32.06,20.229-70.844c0-38.607-19.688-63.935-19.888-64.187c-2.057-2.599-1.616-6.372,0.981-8.428
c2.602-2.057,6.373-1.616,8.429,0.982c0.918,1.16,22.478,28.897,22.478,71.633c0,42.416-21.231,75.928-22.136,77.334
C192.379,204.027,190.446,205,188.473,205z"/>
<path display="inline" d="M216.731,228.861c-1.11,0-2.234-0.309-3.238-0.954c-2.788-1.791-3.595-5.504-1.803-8.291
c0.267-0.418,26.991-42.736,26.991-94.506c0-51.593-26.383-85.533-26.649-85.87c-2.057-2.599-1.616-6.372,0.982-8.428
c2.599-2.057,6.371-1.616,8.428,0.982c1.194,1.509,29.239,37.593,29.239,93.316c0,55.402-27.717,99.16-28.897,100.995
C220.638,227.889,218.705,228.861,216.731,228.861z"/>
</g>
<g id="Muted">
<path d="M160.503,221.101c-1.717,0-3.421-0.732-4.608-2.153L10.395,44.746c-2.125-2.543-1.785-6.327,0.759-8.451
c2.544-2.125,6.328-1.784,8.451,0.759l145.5,174.201c2.124,2.544,1.784,6.327-0.759,8.452
C163.224,220.644,161.859,221.101,160.503,221.101z"/>
<path d="M127.5,203.984l-62.458-41C64.064,162.342,62.92,162,61.75,162h-47V94h28.967L33.694,82H8.75c-3.313,0-6,2.687-6,6v80
c0,3.313,2.687,6,6,6h51.207l70.25,46.116c0.997,0.654,2.144,0.984,3.293,0.984c0.979,0,1.958-0.238,2.85-0.72
c1.94-1.048,3.15-3.075,3.15-5.28v-6.423l-12-14.367V203.984z"/>
<path d="M127.5,52.016v104.856l12,14.367V40.899c0-2.205-1.21-4.232-3.15-5.28c-1.939-1.047-4.299-0.947-6.143,0.264L63.19,79.877
l7.744,9.271L127.5,52.016z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,84 @@
using System;
using System.Threading.Tasks;
using System.Windows.Controls;
using AudioSwitcher.AudioApi;
using Microsoft.Extensions.Logging;
namespace MassiveKnob.Plugin.CoreAudio.SetDefault
{
public class DeviceSetDefaultAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("b76f1eb7-2419-42b4-9de4-9bfe6f65a841");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.InputDigital;
public string Name { get; } = Strings.SetDefaultName;
public string Description { get; } = Strings.SetDefaultDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobDigitalAction
{
private IMassiveKnobActionContext actionContext;
private DeviceSetDefaultActionSettings settings;
private IDevice playbackDevice;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<DeviceSetDefaultActionSettings>();
ApplySettings();
}
public void Dispose()
{
}
private void ApplySettings()
{
var coreAudioController = CoreAudioControllerInstance.Acquire();
playbackDevice = settings.DeviceId.HasValue ? coreAudioController.GetDevice(settings.DeviceId.Value) : null;
}
public UserControl CreateSettingsControl()
{
var viewModel = new DeviceSetDefaultActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new DeviceSetDefaultActionSettingsView(viewModel);
}
public async ValueTask DigitalChanged(bool on)
{
if (playbackDevice == null || !on)
return;
if (settings.Playback)
await playbackDevice.SetAsDefaultAsync();
if (settings.Communications)
await playbackDevice.SetAsDefaultCommunicationsAsync();
// TODO OSD for default device
//if (settings.OSD)
//OSDManager.Show(playbackDevice);
}
}
}
}

View File

@ -0,0 +1,10 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.SetDefault
{
public class DeviceSetDefaultActionSettings : BaseDeviceSettings
{
public bool Playback { get; set; } = true;
public bool Communications { get; set; } = true;
}
}

View File

@ -0,0 +1,23 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.SetDefault.DeviceSetDefaultActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:base="clr-namespace:MassiveKnob.Plugin.CoreAudio.Base"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.CoreAudio"
xmlns:setDefault="clr-namespace:MassiveKnob.Plugin.CoreAudio.SetDefault"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance setDefault:DeviceSetDefaultActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseDeviceSettingsView />
<CheckBox Margin="0,24,0,0" IsChecked="{Binding Playback}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingSetDefaultPlayback}" />
</CheckBox>
<CheckBox Margin="0,8,0,0" IsChecked="{Binding Communications}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingSetDefaultCommunications}" />
</CheckBox>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.CoreAudio.SetDefault
{
/// <summary>
/// Interaction logic for DeviceSetDefaultActionSettingsView.xaml
/// </summary>
public partial class DeviceSetDefaultActionSettingsView
{
public DeviceSetDefaultActionSettingsView(DeviceSetDefaultActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,41 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.SetDefault
{
public class DeviceSetDefaultActionSettingsViewModel : BaseDeviceSettingsViewModel<DeviceSetDefaultActionSettings>
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public bool Playback
{
get => Settings.Playback;
set
{
if (value == Settings.Playback)
return;
Settings.Playback = value;
OnPropertyChanged();
}
}
public bool Communications
{
get => Settings.Communications;
set
{
if (value == Settings.Communications)
return;
Settings.Communications = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
// ReSharper disable once SuggestBaseTypeForParameter - by design
public DeviceSetDefaultActionSettingsViewModel(DeviceSetDefaultActionSettings settings) : base(settings)
{
}
}
}

View File

@ -0,0 +1,88 @@
using System;
using System.Threading.Tasks;
using System.Windows.Controls;
using AudioSwitcher.AudioApi;
using MassiveKnob.Plugin.CoreAudio.OSD;
using Microsoft.Extensions.Logging;
namespace MassiveKnob.Plugin.CoreAudio.SetMuted
{
public class DeviceSetMutedAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("032eb405-a1df-4178-b2d5-6cf556305a8c");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.InputDigital;
public string Name { get; } = Strings.SetMutedName;
public string Description { get; } = Strings.SetMutedDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobDigitalAction
{
private IMassiveKnobActionContext actionContext;
private DeviceSetMutedActionSettings settings;
private IDevice playbackDevice;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<DeviceSetMutedActionSettings>();
ApplySettings();
}
public void Dispose()
{
}
private void ApplySettings()
{
var coreAudioController = CoreAudioControllerInstance.Acquire();
playbackDevice = settings.DeviceId.HasValue ? coreAudioController.GetDevice(settings.DeviceId.Value) : null;
}
public UserControl CreateSettingsControl()
{
var viewModel = new DeviceSetMutedActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new DeviceSetMutedActionSettingsView(viewModel);
}
public async ValueTask DigitalChanged(bool on)
{
if (playbackDevice == null)
return;
if (settings.Toggle)
{
if (!on)
return;
await playbackDevice.SetMuteAsync(!playbackDevice.IsMuted);
}
else
await playbackDevice.SetMuteAsync(settings.SetInverted ? !on : on);
if (settings.OSD)
OSDManager.Show(playbackDevice);
}
}
}
}

View File

@ -0,0 +1,10 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.SetMuted
{
public class DeviceSetMutedActionSettings : BaseDeviceSettings
{
public bool Toggle { get; set; }
public bool SetInverted { get; set;}
}
}

View File

@ -0,0 +1,27 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.SetMuted.DeviceSetMutedActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:setMuted="clr-namespace:MassiveKnob.Plugin.CoreAudio.SetMuted"
xmlns:base="clr-namespace:MassiveKnob.Plugin.CoreAudio.Base"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.CoreAudio"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance setMuted:DeviceSetMutedActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseDeviceSettingsView />
<RadioButton Margin="0,8,0,0" IsChecked="{Binding ToggleTrue}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingSetMutedToggleTrue}" />
</RadioButton>
<RadioButton Margin="0,8,0,0" IsChecked="{Binding ToggleFalse}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingSetMutedToggleFalse}" />
</RadioButton>
<CheckBox Margin="24,8,0,0" IsChecked="{Binding SetInverted}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingSetMutedSetInverted}" />
</CheckBox>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.CoreAudio.SetMuted
{
/// <summary>
/// Interaction logic for DeviceSetMutedActionSettingsView.xaml
/// </summary>
public partial class DeviceSetMutedActionSettingsView
{
public DeviceSetMutedActionSettingsView(DeviceSetMutedActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,62 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.SetMuted
{
public class DeviceSetMutedActionSettingsViewModel : BaseDeviceSettingsViewModel<DeviceSetMutedActionSettings>
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public bool ToggleTrue
{
get => Settings.Toggle;
set
{
if (!value)
return;
if (Settings.Toggle)
return;
Settings.Toggle = true;
OnPropertyChanged();
}
}
public bool ToggleFalse
{
get => !Settings.Toggle;
set
{
if (!value)
return;
if (!Settings.Toggle)
return;
Settings.Toggle = false;
OnPropertyChanged();
}
}
public bool SetInverted
{
get => Settings.SetInverted;
set
{
if (value == Settings.SetInverted)
return;
Settings.SetInverted = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
// ReSharper disable once SuggestBaseTypeForParameter - by design
public DeviceSetMutedActionSettingsViewModel(DeviceSetMutedActionSettings settings) : base(settings)
{
}
}
}

View File

@ -0,0 +1,79 @@
using System;
using System.Threading.Tasks;
using System.Windows.Controls;
using AudioSwitcher.AudioApi;
using MassiveKnob.Plugin.CoreAudio.OSD;
using Microsoft.Extensions.Logging;
namespace MassiveKnob.Plugin.CoreAudio.SetVolume
{
public class DeviceSetVolumeAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("aabd329c-8be5-4d1e-90ab-5114143b21dd");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.InputAnalog;
public string Name { get; } = Strings.SetVolumeName;
public string Description { get; } = Strings.SetVolumeDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobAnalogAction
{
private IMassiveKnobActionContext actionContext;
private DeviceSetVolumeActionSettings settings;
private IDevice playbackDevice;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<DeviceSetVolumeActionSettings>();
ApplySettings();
}
public void Dispose()
{
}
private void ApplySettings()
{
var coreAudioController = CoreAudioControllerInstance.Acquire();
playbackDevice = settings.DeviceId.HasValue ? coreAudioController.GetDevice(settings.DeviceId.Value) : null;
}
public UserControl CreateSettingsControl()
{
var viewModel = new DeviceSetVolumeActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new DeviceSetVolumeActionSettingsView(viewModel);
}
public async ValueTask AnalogChanged(byte value)
{
if (playbackDevice == null)
return;
await playbackDevice.SetVolumeAsync(value);
if (settings.OSD)
OSDManager.Show(playbackDevice);
}
}
}
}

View File

@ -0,0 +1,8 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.SetVolume
{
public class DeviceSetVolumeActionSettings : BaseDeviceSettings
{
}
}

View File

@ -0,0 +1,14 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.SetVolume.DeviceSetVolumeActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:base="clr-namespace:MassiveKnob.Plugin.CoreAudio.Base"
xmlns:setVolume="clr-namespace:MassiveKnob.Plugin.CoreAudio.SetVolume"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance setVolume:DeviceSetVolumeActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseDeviceSettingsView />
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.CoreAudio.SetVolume
{
/// <summary>
/// Interaction logic for DeviceSetVolumeActionSettingsView.xaml
/// </summary>
public partial class DeviceSetVolumeActionSettingsView
{
public DeviceSetVolumeActionSettingsView(DeviceSetVolumeActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,12 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.SetVolume
{
public class DeviceSetVolumeActionSettingsViewModel : BaseDeviceSettingsViewModel<DeviceSetVolumeActionSettings>
{
// ReSharper disable once SuggestBaseTypeForParameter - by design
public DeviceSetVolumeActionSettingsViewModel(DeviceSetVolumeActionSettings settings) : base(settings)
{
}
}
}

View File

@ -0,0 +1,342 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace MassiveKnob.Plugin.CoreAudio {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Strings {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Strings() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MassiveKnob.Plugin.CoreAudio.Strings", typeof(Strings).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to {0}.
/// </summary>
public static string DeviceDisplayNameActive {
get {
return ResourceManager.GetString("DeviceDisplayNameActive", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Disabled).
/// </summary>
public static string DeviceDisplayNameDisabled {
get {
return ResourceManager.GetString("DeviceDisplayNameDisabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Inactive).
/// </summary>
public static string DeviceDisplayNameInactive {
get {
return ResourceManager.GetString("DeviceDisplayNameInactive", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Not present).
/// </summary>
public static string DeviceDisplayNameNotPresent {
get {
return ResourceManager.GetString("DeviceDisplayNameNotPresent", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Unplugged).
/// </summary>
public static string DeviceDisplayNameUnplugged {
get {
return ResourceManager.GetString("DeviceDisplayNameUnplugged", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Sets the digital output depending on whether the selected device is the active playback or communications device..
/// </summary>
public static string GetDefaultDescription {
get {
return ResourceManager.GetString("GetDefaultDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Is default device.
/// </summary>
public static string GetDefaultName {
get {
return ResourceManager.GetString("GetDefaultName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Sets the digital output to the muted state for the selected device, regardless of the current default device..
/// </summary>
public static string GetMutedDescription {
get {
return ResourceManager.GetString("GetMutedDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Muted / unmuted.
/// </summary>
public static string GetMutedName {
get {
return ResourceManager.GetString("GetMutedName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Sets the analog output to the volume for the selected device, regardless of the current default device..
/// </summary>
public static string GetVolumeDescription {
get {
return ResourceManager.GetString("GetVolumeDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Volume.
/// </summary>
public static string GetVolumeName {
get {
return ResourceManager.GetString("GetVolumeName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Included with Massive Knob by default. Provides volume control per device and default device switching..
/// </summary>
public static string PluginDescription {
get {
return ResourceManager.GetString("PluginDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Core Audio.
/// </summary>
public static string PluginName {
get {
return ResourceManager.GetString("PluginName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Changes the default playback and/or communications device when the input turns on..
/// </summary>
public static string SetDefaultDescription {
get {
return ResourceManager.GetString("SetDefaultDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Set default device.
/// </summary>
public static string SetDefaultName {
get {
return ResourceManager.GetString("SetDefaultName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Toggles the muted state for the selected device, regardless of the current default device..
/// </summary>
public static string SetMutedDescription {
get {
return ResourceManager.GetString("SetMutedDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Mute / unmute.
/// </summary>
public static string SetMutedName {
get {
return ResourceManager.GetString("SetMutedName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to is the default communications device.
/// </summary>
public static string SettingGetDefaultCommunications {
get {
return ResourceManager.GetString("SettingGetDefaultCommunications", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Inverted (off when the device is the default).
/// </summary>
public static string SettingGetDefaultInverted {
get {
return ResourceManager.GetString("SettingGetDefaultInverted", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to is the default playback device.
/// </summary>
public static string SettingGetDefaultPlayback {
get {
return ResourceManager.GetString("SettingGetDefaultPlayback", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Turn on when the selected device.
/// </summary>
public static string SettingGetDefaultWhen {
get {
return ResourceManager.GetString("SettingGetDefaultWhen", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Inverted (off when muted).
/// </summary>
public static string SettingGetMutedInverted {
get {
return ResourceManager.GetString("SettingGetMutedInverted", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show On-Screen Display.
/// </summary>
public static string SettingOSD {
get {
return ResourceManager.GetString("SettingOSD", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Playback device.
/// </summary>
public static string SettingPlaybackDevice {
get {
return ResourceManager.GetString("SettingPlaybackDevice", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Set as the default communications device.
/// </summary>
public static string SettingSetDefaultCommunications {
get {
return ResourceManager.GetString("SettingSetDefaultCommunications", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Set as the default playback device.
/// </summary>
public static string SettingSetDefaultPlayback {
get {
return ResourceManager.GetString("SettingSetDefaultPlayback", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Inverted (muted when off).
/// </summary>
public static string SettingSetMutedSetInverted {
get {
return ResourceManager.GetString("SettingSetMutedSetInverted", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Set mute depending on value (eg. switch).
/// </summary>
public static string SettingSetMutedToggleFalse {
get {
return ResourceManager.GetString("SettingSetMutedToggleFalse", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Toggle mute when input turns on (eg. push button).
/// </summary>
public static string SettingSetMutedToggleTrue {
get {
return ResourceManager.GetString("SettingSetMutedToggleTrue", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Sets the volume for the selected device to the value of the analog input, regardless of the current default device..
/// </summary>
public static string SetVolumeDescription {
get {
return ResourceManager.GetString("SetVolumeDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Volume.
/// </summary>
public static string SetVolumeName {
get {
return ResourceManager.GetString("SetVolumeName", resourceCulture);
}
}
}
}

View File

@ -0,0 +1,213 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="DeviceDisplayNameActive" xml:space="preserve">
<value>{0}</value>
</data>
<data name="DeviceDisplayNameDisabled" xml:space="preserve">
<value>{0} (Disabled)</value>
</data>
<data name="DeviceDisplayNameInactive" xml:space="preserve">
<value>{0} (Inactive)</value>
</data>
<data name="DeviceDisplayNameNotPresent" xml:space="preserve">
<value>{0} (Not present)</value>
</data>
<data name="DeviceDisplayNameUnplugged" xml:space="preserve">
<value>{0} (Unplugged)</value>
</data>
<data name="GetDefaultDescription" xml:space="preserve">
<value>Sets the digital output depending on whether the selected device is the active playback or communications device.</value>
</data>
<data name="GetDefaultName" xml:space="preserve">
<value>Is default device</value>
</data>
<data name="GetMutedDescription" xml:space="preserve">
<value>Sets the digital output to the muted state for the selected device, regardless of the current default device.</value>
</data>
<data name="GetMutedName" xml:space="preserve">
<value>Muted / unmuted</value>
</data>
<data name="GetVolumeDescription" xml:space="preserve">
<value>Sets the analog output to the volume for the selected device, regardless of the current default device.</value>
</data>
<data name="GetVolumeName" xml:space="preserve">
<value>Volume</value>
</data>
<data name="PluginDescription" xml:space="preserve">
<value>Included with Massive Knob by default. Provides volume control per device and default device switching.</value>
</data>
<data name="PluginName" xml:space="preserve">
<value>Windows Core Audio</value>
</data>
<data name="SetDefaultDescription" xml:space="preserve">
<value>Changes the default playback and/or communications device when the input turns on.</value>
</data>
<data name="SetDefaultName" xml:space="preserve">
<value>Set default device</value>
</data>
<data name="SetMutedDescription" xml:space="preserve">
<value>Toggles the muted state for the selected device, regardless of the current default device.</value>
</data>
<data name="SetMutedName" xml:space="preserve">
<value>Mute / unmute</value>
</data>
<data name="SettingGetDefaultCommunications" xml:space="preserve">
<value>is the default communications device</value>
</data>
<data name="SettingGetDefaultInverted" xml:space="preserve">
<value>Inverted (off when the device is the default)</value>
</data>
<data name="SettingGetDefaultPlayback" xml:space="preserve">
<value>is the default playback device</value>
</data>
<data name="SettingGetDefaultWhen" xml:space="preserve">
<value>Turn on when the selected device</value>
</data>
<data name="SettingGetMutedInverted" xml:space="preserve">
<value>Inverted (off when muted)</value>
</data>
<data name="SettingOSD" xml:space="preserve">
<value>Show On-Screen Display</value>
</data>
<data name="SettingPlaybackDevice" xml:space="preserve">
<value>Playback device</value>
</data>
<data name="SettingSetDefaultCommunications" xml:space="preserve">
<value>Set as the default communications device</value>
</data>
<data name="SettingSetDefaultPlayback" xml:space="preserve">
<value>Set as the default playback device</value>
</data>
<data name="SettingSetMutedSetInverted" xml:space="preserve">
<value>Inverted (muted when off)</value>
</data>
<data name="SettingSetMutedToggleFalse" xml:space="preserve">
<value>Set mute depending on value (eg. switch)</value>
</data>
<data name="SettingSetMutedToggleTrue" xml:space="preserve">
<value>Toggle mute when input turns on (eg. push button)</value>
</data>
<data name="SetVolumeDescription" xml:space="preserve">
<value>Sets the volume for the selected device to the value of the analog input, regardless of the current default device.</value>
</data>
<data name="SetVolumeName" xml:space="preserve">
<value>Volume</value>
</data>
</root>

View File

@ -0,0 +1,97 @@
using System;
using System.Windows.Controls;
using MassiveKnob.Plugin.EmulatorDevice.Settings;
using Microsoft.Extensions.Logging;
namespace MassiveKnob.Plugin.EmulatorDevice.Devices
{
public class EmulatorDevice : IMassiveKnobDevice
{
public Guid DeviceId { get; } = new Guid("e1a4977a-abf4-4c75-a17d-fd8d3a8451ff");
public string Name { get; } = "Emulator";
public string Description { get; } = "Emulates an actual device but does not communicate with anything.";
public IMassiveKnobDeviceInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobDeviceInstance
{
private IMassiveKnobDeviceContext deviceContext;
private EmulatorDeviceSettings settings;
private DeviceSpecs reportedSpecs;
private EmulatorDeviceWindow window;
private EmulatorDeviceWindowViewModel windowViewModel;
public void Initialize(IMassiveKnobDeviceContext context)
{
deviceContext = context;
settings = deviceContext.GetSettings<EmulatorDeviceSettings>();
windowViewModel = new EmulatorDeviceWindowViewModel(settings, context);
window = new EmulatorDeviceWindow(windowViewModel);
ApplySettings();
}
public void Dispose()
{
window.Close();
}
private void ApplySettings()
{
if (settings.AnalogInputCount != reportedSpecs.AnalogInputCount ||
settings.DigitalInputCount != reportedSpecs.DigitalInputCount ||
settings.AnalogOutputCount != reportedSpecs.AnalogOutputCount ||
settings.DigitalOutputCount != reportedSpecs.DigitalOutputCount)
{
reportedSpecs = new DeviceSpecs(
settings.AnalogInputCount, settings.DigitalInputCount,
settings.AnalogOutputCount, settings.DigitalOutputCount);
deviceContext.Connected(reportedSpecs);
}
windowViewModel.ApplySettings();
window.Show();
}
public UserControl CreateSettingsControl()
{
var viewModel = new EmulatorDeviceSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
deviceContext.SetSettings(settings);
ApplySettings();
};
return new EmulatorDeviceSettingsView(viewModel);
}
public void SetAnalogOutput(int analogOutputIndex, byte value)
{
if (analogOutputIndex >= windowViewModel.AnalogOutputCount)
return;
windowViewModel.AnalogOutputs[analogOutputIndex].AnalogValue = value;
}
public void SetDigitalOutput(int digitalOutputIndex, bool on)
{
if (digitalOutputIndex >= windowViewModel.DigitalOutputCount)
return;
windowViewModel.DigitalOutputs[digitalOutputIndex].DigitalValue = on;
}
}
}
}

View File

@ -0,0 +1,75 @@
<Window x:Class="MassiveKnob.Plugin.EmulatorDevice.Devices.EmulatorDeviceWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:devices="clr-namespace:MassiveKnob.Plugin.EmulatorDevice.Devices"
mc:Ignorable="d"
Title="Massive Knob - Device Emulator" Height="400" Width="300"
WindowStartupLocation="CenterScreen"
WindowStyle="ToolWindow"
Topmost="True"
d:DataContext="{d:DesignInstance devices:EmulatorDeviceWindowViewModelDesignTime, IsDesignTimeCreatable=True}">
<Window.Resources>
<ResourceDictionary>
<Style TargetType="DockPanel" x:Key="Row">
</Style>
<Style TargetType="TextBlock" x:Key="Label">
<Setter Property="Margin" Value="4,4,8,4" />
<Setter Property="DockPanel.Dock" Value="Left" />
</Style>
<Style x:Key="Value">
<Setter Property="Control.Margin" Value="4,4,8,4" />
</Style>
</ResourceDictionary>
</Window.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<ItemsControl ItemsSource="{Binding AnalogInputs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Style="{StaticResource Row}">
<TextBlock Text="{Binding DisplayName}" Style="{StaticResource Label}" />
<Slider Minimum="0" Maximum="100" Value="{Binding AnalogValue}" Style="{StaticResource Value}" />
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding DigitalInputs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Style="{StaticResource Row}">
<TextBlock Text="{Binding DisplayName}" Style="{StaticResource Label}" />
<CheckBox IsChecked="{Binding DigitalValue}" Style="{StaticResource Value}" />
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding AnalogOutputs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Style="{StaticResource Row}">
<TextBlock Text="{Binding DisplayName}" Style="{StaticResource Label}" />
<TextBlock Text="{Binding AnalogValue}" Style="{StaticResource Value}" />
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding DigitalOutputs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Style="{StaticResource Row}">
<TextBlock Text="{Binding DisplayName}" Style="{StaticResource Label}" />
<TextBlock Text="{Binding DigitalValueDisplayText}" Style="{StaticResource Value}" />
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</Window>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.EmulatorDevice.Devices
{
/// <summary>
/// Interaction logic for EmulatorDeviceWindow.xaml
/// </summary>
public partial class EmulatorDeviceWindow
{
public EmulatorDeviceWindow(EmulatorDeviceWindowViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,263 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using MassiveKnob.Plugin.EmulatorDevice.Settings;
namespace MassiveKnob.Plugin.EmulatorDevice.Devices
{
public class EmulatorDeviceWindowViewModel : INotifyPropertyChanged
{
private readonly EmulatorDeviceSettings settings;
private readonly IMassiveKnobDeviceContext context;
public event PropertyChangedEventHandler PropertyChanged;
// ReSharper disable UnusedMember.Global - used by WPF Binding
private int analogInputCount;
public int AnalogInputCount
{
get => analogInputCount;
set
{
if (value == analogInputCount)
return;
analogInputCount = value;
OnPropertyChanged();
AnalogInputs = Enumerable.Range(0, AnalogInputCount)
.Select(i => new InputOutputViewModel(context, MassiveKnobActionType.InputAnalog, i))
.ToList();
}
}
private IList<InputOutputViewModel> analogInputs;
public IList<InputOutputViewModel> AnalogInputs
{
get => analogInputs;
set
{
analogInputs = value;
OnPropertyChanged();
}
}
private int digitalInputCount;
public int DigitalInputCount
{
get => digitalInputCount;
set
{
if (value == digitalInputCount)
return;
digitalInputCount = value;
OnPropertyChanged();
DigitalInputs = Enumerable.Range(0, DigitalInputCount)
.Select(i => new InputOutputViewModel(context, MassiveKnobActionType.InputDigital, i))
.ToList();
}
}
private IList<InputOutputViewModel> digitalInputs;
public IList<InputOutputViewModel> DigitalInputs
{
get => digitalInputs;
set
{
digitalInputs = value;
OnPropertyChanged();
}
}
private int analogOutputCount;
public int AnalogOutputCount
{
get => analogOutputCount;
set
{
if (value == analogOutputCount)
return;
analogOutputCount = value;
OnPropertyChanged();
AnalogOutputs = Enumerable.Range(0, AnalogOutputCount)
.Select(i => new InputOutputViewModel(context, MassiveKnobActionType.OutputAnalog, i))
.ToList();
}
}
private IList<InputOutputViewModel> analogOutputs;
public IList<InputOutputViewModel> AnalogOutputs
{
get => analogOutputs;
set
{
analogOutputs = value;
OnPropertyChanged();
}
}
private int digitalOutputCount;
public int DigitalOutputCount
{
get => digitalOutputCount;
set
{
if (value == digitalOutputCount)
return;
digitalOutputCount = value;
OnPropertyChanged();
DigitalOutputs = Enumerable.Range(0, DigitalOutputCount)
.Select(i => new InputOutputViewModel(context, MassiveKnobActionType.OutputDigital, i))
.ToList();
}
}
private IList<InputOutputViewModel> digitalOutputs;
public IList<InputOutputViewModel> DigitalOutputs
{
get => digitalOutputs;
set
{
digitalOutputs = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
public EmulatorDeviceWindowViewModel(EmulatorDeviceSettings settings, IMassiveKnobDeviceContext context)
{
this.settings = settings;
this.context = context;
ApplySettings();
}
public void ApplySettings()
{
AnalogInputCount = settings.AnalogInputCount;
DigitalInputCount = settings.DigitalInputCount;
AnalogOutputCount = settings.AnalogOutputCount;
DigitalOutputCount = settings.DigitalOutputCount;
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public class InputOutputViewModel : INotifyPropertyChanged
{
private readonly IMassiveKnobDeviceContext context;
public MassiveKnobActionType ActionType { get; }
public int Index { get; }
public string DisplayName
{
get
{
switch (ActionType)
{
case MassiveKnobActionType.InputAnalog:
return $"Analog input #{Index + 1}";
case MassiveKnobActionType.InputDigital:
return $"Digital input #{Index + 1}";
case MassiveKnobActionType.OutputAnalog:
return $"Analog output #{Index + 1}";
case MassiveKnobActionType.OutputDigital:
return $"Digital output #{Index + 1}";
default:
return (Index + 1).ToString();
}
}
}
private byte analogValue;
public byte AnalogValue
{
get => analogValue;
set
{
analogValue = value;
OnPropertyChanged();
if (ActionType == MassiveKnobActionType.InputAnalog)
// Context can be null in DesignTime
context?.AnalogChanged(Index, analogValue);
}
}
private bool digitalValue;
public bool DigitalValue
{
get => digitalValue;
set
{
digitalValue = value;
OnPropertyChanged();
OnDependantPropertyChanged("DigitalValueDisplayText");
if (ActionType == MassiveKnobActionType.InputDigital)
context?.DigitalChanged(Index, digitalValue);
}
}
public string DigitalValueDisplayText => DigitalValue ? "On" : "Off";
public InputOutputViewModel(IMassiveKnobDeviceContext context, MassiveKnobActionType actionType, int index)
{
this.context = context;
ActionType = actionType;
Index = index;
}
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected void OnDependantPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
public class EmulatorDeviceWindowViewModelDesignTime : EmulatorDeviceWindowViewModel
{
public EmulatorDeviceWindowViewModelDesignTime() : base(
new EmulatorDeviceSettings
{
AnalogInputCount = 2,
DigitalInputCount = 2,
AnalogOutputCount = 2,
DigitalOutputCount = 2
}, null)
{
}
}
}

View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{674DE974-B134-4DB5-BFAF-7BC3D05E16DE}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>MassiveKnob.Plugin.EmulatorDevice</RootNamespace>
<AssemblyName>MassiveKnob.Plugin.EmulatorDevice</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xaml" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
<Reference Include="WindowsBase" />
</ItemGroup>
<ItemGroup>
<Compile Include="Devices\EmulatorDevice.cs" />
<Compile Include="Devices\EmulatorDeviceWindow.xaml.cs">
<DependentUpon>EmulatorDeviceWindow.xaml</DependentUpon>
</Compile>
<Compile Include="Devices\EmulatorDeviceWindowViewModel.cs" />
<Compile Include="MassiveKnobEmulatorDevicePlugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Settings\EmulatorDeviceSettingsView.xaml.cs">
<DependentUpon>EmulatorDeviceSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="Settings\EmulatorDeviceSettings.cs" />
<Compile Include="Settings\EmulatorDeviceSettingsViewModel.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MassiveKnob.Plugin\MassiveKnob.Plugin.csproj">
<Project>{A1298BE4-1D23-416C-8C56-FC9264487A95}</Project>
<Name>MassiveKnob.Plugin</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions">
<Version>5.0.0</Version>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Page Include="Devices\EmulatorDeviceWindow.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="Settings\EmulatorDeviceSettingsView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<None Include="MassiveKnobPlugin.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace MassiveKnob.Plugin.EmulatorDevice
{
[MassiveKnobPlugin]
public class MassiveKnobEmulatorDevicePlugin : IMassiveKnobDevicePlugin
{
public Guid PluginId { get; } = new Guid("85f04232-d70f-494c-94a2-41452591ffb3");
public string Name { get; } = "Mock Device";
public string Description { get; } = "Emulates the actual device but does not communicate with anything.";
public string Author { get; } = "Mark van Renswoude <mark@x2software.net>";
public string Url { get; } = "https://www.github.com/MvRens/MassiveKnob/";
public IEnumerable<IMassiveKnobDevice> Devices { get; } = new IMassiveKnobDevice[]
{
new EmulatorDevice.Devices.EmulatorDevice()
};
}
}

View File

@ -0,0 +1,3 @@
{
"EntryAssembly": "MassiveKnob.Plugin.EmulatorDevice.dll"
}

View File

@ -0,0 +1,35 @@
using System.Reflection;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MassiveKnob.Plugin.MockDevice")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("MassiveKnob.Plugin.MockDevice")]
[assembly: AssemblyCopyright("Copyright © 2021")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("674de974-b134-4db5-bfaf-7bc3d05e16de")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

View File

@ -0,0 +1,10 @@
namespace MassiveKnob.Plugin.EmulatorDevice.Settings
{
public class EmulatorDeviceSettings
{
public int AnalogInputCount { get; set; } = 2;
public int DigitalInputCount { get; set; } = 2;
public int AnalogOutputCount { get; set; } = 2;
public int DigitalOutputCount { get; set; } = 2;
}
}

View File

@ -0,0 +1,34 @@
<UserControl x:Class="MassiveKnob.Plugin.EmulatorDevice.Settings.EmulatorDeviceSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:settings="clr-namespace:MassiveKnob.Plugin.EmulatorDevice.Settings"
mc:Ignorable="d" d:DesignWidth="800"
d:DataContext="{d:DesignInstance Type=settings:EmulatorDeviceSettingsViewModel}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Margin="4">Analog inputs</TextBlock>
<TextBox Grid.Row="0" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" Text="{Binding AnalogInputCount}" />
<TextBlock Grid.Row="1" Grid.Column="0" Margin="4">Digital inputs</TextBlock>
<TextBox Grid.Row="1" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" Text="{Binding DigitalInputCount}" />
<TextBlock Grid.Row="2" Grid.Column="0" Margin="4">Analog outputs</TextBlock>
<TextBox Grid.Row="2" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" Text="{Binding AnalogOutputCount}" />
<TextBlock Grid.Row="3" Grid.Column="0" Margin="4">Digital outputs</TextBlock>
<TextBox Grid.Row="3" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" Text="{Binding DigitalOutputCount}" />
</Grid>
</UserControl>

View File

@ -0,0 +1,15 @@
namespace MassiveKnob.Plugin.EmulatorDevice.Settings
{
/// <summary>
/// Interaction logic for EmulatorDeviceSettingsView.xaml
/// </summary>
public partial class EmulatorDeviceSettingsView
{
public EmulatorDeviceSettingsView(EmulatorDeviceSettingsViewModel settingsViewModel)
{
DataContext = settingsViewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,81 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace MassiveKnob.Plugin.EmulatorDevice.Settings
{
public class EmulatorDeviceSettingsViewModel : INotifyPropertyChanged
{
private readonly EmulatorDeviceSettings settings;
public event PropertyChangedEventHandler PropertyChanged;
// ReSharper disable UnusedMember.Global - used by WPF Binding
public int AnalogInputCount
{
get => settings.AnalogInputCount;
set
{
if (value == settings.AnalogInputCount)
return;
settings.AnalogInputCount = value;
OnPropertyChanged();
}
}
public int DigitalInputCount
{
get => settings.DigitalInputCount;
set
{
if (value == settings.DigitalInputCount)
return;
settings.DigitalInputCount = value;
OnPropertyChanged();
}
}
public int AnalogOutputCount
{
get => settings.AnalogOutputCount;
set
{
if (value == settings.AnalogOutputCount)
return;
settings.AnalogOutputCount = value;
OnPropertyChanged();
}
}
public int DigitalOutputCount
{
get => settings.DigitalOutputCount;
set
{
if (value == settings.DigitalOutputCount)
return;
settings.DigitalOutputCount = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
public EmulatorDeviceSettingsViewModel(EmulatorDeviceSettings settings)
{
this.settings = settings;
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}

View File

@ -0,0 +1,84 @@
using System;
using System.Windows.Controls;
using MassiveKnob.Plugin.SerialDevice.Settings;
using MassiveKnob.Plugin.SerialDevice.Worker;
using Microsoft.Extensions.Logging;
namespace MassiveKnob.Plugin.SerialDevice.Devices
{
public class SerialDevice : IMassiveKnobDevice
{
public Guid DeviceId { get; } = new Guid("65255f25-d8f6-426b-8f12-cf03c44a1bf5");
public string Name { get; } = "Serial device";
public string Description { get; } = "A Serial (USB) device which implements the Massive Knob Protocol.";
public IMassiveKnobDeviceInstance Create(ILogger logger)
{
return new Instance(logger);
}
private class Instance : IMassiveKnobDeviceInstance
{
private readonly ILogger logger;
private IMassiveKnobDeviceContext deviceContext;
private SerialDeviceSettings settings;
private SerialWorker worker;
public Instance(ILogger logger)
{
this.logger = logger;
}
public void Initialize(IMassiveKnobDeviceContext context)
{
deviceContext = context;
settings = deviceContext.GetSettings<SerialDeviceSettings>();
worker = new SerialWorker(context, logger);
ApplySettings();
}
public void Dispose()
{
worker.Dispose();
}
private void ApplySettings()
{
worker.Connect(settings.PortName, settings.BaudRate, settings.DtrEnable);
}
public UserControl CreateSettingsControl()
{
var viewModel = new SerialDeviceSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
deviceContext.SetSettings(settings);
ApplySettings();
};
return new SerialDeviceSettingsView(viewModel);
}
public void SetAnalogOutput(int analogOutputIndex, byte value)
{
worker.SetAnalogOutput(analogOutputIndex, value);
}
public void SetDigitalOutput(int digitalOutputIndex, bool on)
{
worker.SetDigitalOutput(digitalOutputIndex, on);
}
}
}
}

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{FC0D22D8-5F1B-4D85-8269-FA4837CDE3A2}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>MassiveKnob.Plugin.SerialDevice</RootNamespace>
<AssemblyName>MassiveKnob.Plugin.SerialDevice</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xaml" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
<Reference Include="WindowsBase" />
</ItemGroup>
<ItemGroup>
<Compile Include="Devices\SerialDevice.cs" />
<Compile Include="MassiveKnobSerialDevicePlugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Settings\SerialDeviceSettingsView.xaml.cs">
<DependentUpon>SerialDeviceSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="Settings\SerialDeviceSettingsViewModel.cs" />
<Compile Include="Settings\SerialDeviceSettings.cs" />
<Compile Include="Worker\SerialWorker.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MassiveKnob.Plugin\MassiveKnob.Plugin.csproj">
<Project>{A1298BE4-1D23-416C-8C56-FC9264487A95}</Project>
<Name>MassiveKnob.Plugin</Name>
</ProjectReference>
<ProjectReference Include="..\min.NET\MIN.SerialPort\MIN.SerialPort.csproj">
<Project>{db8819eb-d2b7-4aae-a699-bd200f2c113a}</Project>
<Name>MIN.SerialPort</Name>
</ProjectReference>
<ProjectReference Include="..\min.NET\MIN\MIN.csproj">
<Project>{fc1c9cb5-8b71-4039-9636-90e578a71061}</Project>
<Name>MIN</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Page Include="Settings\SerialDeviceSettingsView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Crc32.NET">
<Version>1.2.0</Version>
</PackageReference>
<PackageReference Include="Dapplo.Windows.Devices">
<Version>0.11.24</Version>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions">
<Version>5.0.0</Version>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Include="MassiveKnobPlugin.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@ -0,0 +1,3 @@
{
"EntryAssembly": "MassiveKnob.Plugin.SerialDevice.dll"
}

View File

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace MassiveKnob.Plugin.SerialDevice
{
[MassiveKnobPlugin]
public class MassiveKnobSerialDevicePlugin : IMassiveKnobDevicePlugin
{
public Guid PluginId { get; } = new Guid("276475e6-5ff0-420f-82dc-8aff5e8631d5");
public string Name { get; } = "Serial Device";
public string Description { get; } = "A Serial (USB) device which implements the Massive Knob Protocol.";
public string Author { get; } = "Mark van Renswoude <mark@x2software.net>";
public string Url { get; } = "https://www.github.com/MvRens/MassiveKnob/";
public IEnumerable<IMassiveKnobDevice> Devices { get; } = new IMassiveKnobDevice[]
{
new Devices.SerialDevice()
};
}
}

View File

@ -0,0 +1,35 @@
using System.Reflection;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MassiveKnob.Plugin.SerialDevice")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("MassiveKnob.Plugin.SerialDevice")]
[assembly: AssemblyCopyright("Copyright © 2021")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("fc0d22d8-5f1b-4d85-8269-fa4837cde3a2")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

View File

@ -0,0 +1,9 @@
namespace MassiveKnob.Plugin.SerialDevice.Settings
{
public class SerialDeviceSettings
{
public string PortName { get; set; } = null;
public int BaudRate { get; set; } = 115200;
public bool DtrEnable { get; set; } = false;
}
}

View File

@ -0,0 +1,33 @@
<UserControl x:Class="MassiveKnob.Plugin.SerialDevice.Settings.SerialDeviceSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:settings="clr-namespace:MassiveKnob.Plugin.SerialDevice.Settings"
mc:Ignorable="d"
d:DesignHeight="100" d:DesignWidth="800"
d:DataContext="{d:DesignInstance settings:SerialDeviceSettingsViewModel}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Margin="4">Serial port</TextBlock>
<ComboBox Grid.Row="0" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" ItemsSource="{Binding SerialPorts}" SelectedItem="{Binding PortName}" />
<TextBlock Grid.Row="1" Grid.Column="0" Margin="4">Baud rate</TextBlock>
<TextBox Grid.Row="1" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" Text="{Binding BaudRate}" />
<CheckBox Grid.Row="2" Grid.Column="1" Margin="4" HorizontalAlignment="Left" IsChecked="{Binding DtrEnable}">
<TextBlock>Enable DTR (may be required on some Arduino's like Leonardo / Pro Micro)</TextBlock>
</CheckBox>
</Grid>
</UserControl>

View File

@ -0,0 +1,22 @@
using System;
namespace MassiveKnob.Plugin.SerialDevice.Settings
{
/// <summary>
/// Interaction logic for SerialDeviceSettingsView.xaml
/// </summary>
public partial class SerialDeviceSettingsView : IDisposable
{
public SerialDeviceSettingsView(SerialDeviceSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
public void Dispose()
{
(DataContext as SerialDeviceSettingsViewModel)?.Dispose();
}
}
}

View File

@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO.Ports;
using System.Runtime.CompilerServices;
using Dapplo.Windows.Devices;
using Dapplo.Windows.Devices.Enums;
namespace MassiveKnob.Plugin.SerialDevice.Settings
{
public class SerialDeviceSettingsViewModel : IDisposable, INotifyPropertyChanged, IObserver<DeviceNotificationEvent>
{
private readonly SerialDeviceSettings settings;
private IList<string> serialPorts;
private readonly IDisposable deviceSubscription;
public event PropertyChangedEventHandler PropertyChanged;
// ReSharper disable UnusedMember.Global - used by WPF Binding
public IList<string> SerialPorts
{
get => serialPorts;
set
{
serialPorts = value;
OnPropertyChanged();
}
}
public string PortName
{
get => settings.PortName;
set
{
if (value == settings.PortName || value == null)
return;
settings.PortName = value;
OnPropertyChanged();
}
}
public int BaudRate
{
get => settings.BaudRate;
set
{
if (value == settings.BaudRate)
return;
settings.BaudRate = value;
OnPropertyChanged();
}
}
public bool DtrEnable
{
get => settings.DtrEnable;
set
{
if (value == settings.DtrEnable)
return;
settings.DtrEnable = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
public SerialDeviceSettingsViewModel(SerialDeviceSettings settings)
{
this.settings = settings;
serialPorts = SerialPort.GetPortNames();
deviceSubscription = DeviceNotification.OnNotification.Subscribe(this);
}
public void Dispose()
{
deviceSubscription.Dispose();
}
public bool IsSettingsProperty(string propertyName)
{
return propertyName != nameof(SerialPorts);
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected virtual void OnOtherPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public void OnNext(DeviceNotificationEvent value)
{
if ((value.EventType == DeviceChangeEvent.DeviceArrival ||
value.EventType == DeviceChangeEvent.DeviceRemoveComplete) &&
value.Is(DeviceBroadcastDeviceType.DeviceInterface))
{
SerialPorts = SerialPort.GetPortNames();
}
}
public void OnError(Exception error)
{
}
public void OnCompleted()
{
}
}
}

View File

@ -0,0 +1,377 @@
using System;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MIN;
using MIN.Abstractions;
using MIN.SerialPort;
namespace MassiveKnob.Plugin.SerialDevice.Worker
{
public class SerialWorker : IDisposable
{
private readonly IMassiveKnobDeviceContext context;
private readonly ILogger logger;
private readonly object minProtocolLock = new object();
private IMINProtocol minProtocol;
private string lastPortName;
private int lastBaudRate;
private bool lastDtrEnable;
private enum MassiveKnobFrameID
{
Handshake = 42,
HandshakeResponse = 43,
AnalogInput = 1,
DigitalInput = 2,
AnalogOutput = 3,
DigitalOutput = 4,
Quit = 62,
Error = 63
}
public SerialWorker(IMassiveKnobDeviceContext context, ILogger logger)
{
this.context = context;
this.logger = logger;
}
public void Dispose()
{
Disconnect();
}
public void Connect(string portName, int baudRate, bool dtrEnable)
{
context.Connecting();
lock (minProtocolLock)
{
if (portName == lastPortName && baudRate == lastBaudRate && dtrEnable == lastDtrEnable)
return;
lastPortName = portName;
lastBaudRate = baudRate;
lastDtrEnable = dtrEnable;
Disconnect();
if (string.IsNullOrEmpty(portName) || baudRate == 0)
return;
minProtocol?.Dispose();
minProtocol = new MINProtocol(new MINSerialTransport(portName, baudRate, dtrEnable: dtrEnable), logger);
minProtocol.OnConnected += MinProtocolOnOnConnected;
minProtocol.OnFrame += MinProtocolOnOnFrame;
minProtocol.Start();
}
}
public void SetAnalogOutput(int analogOutputIndex, byte value)
{
IMINProtocol instance;
lock (minProtocolLock)
{
instance = minProtocol;
}
instance?.QueueFrame(
(byte)MassiveKnobFrameID.AnalogOutput,
new [] { (byte)analogOutputIndex, value });
}
public void SetDigitalOutput(int digitalOutputIndex, bool on)
{
IMINProtocol instance;
lock (minProtocolLock)
{
instance = minProtocol;
}
instance?.QueueFrame(
(byte)MassiveKnobFrameID.DigitalOutput,
new [] { (byte)digitalOutputIndex, on ? (byte)1 : (byte)0 });
}
private void MinProtocolOnOnConnected(object sender, EventArgs e)
{
IMINProtocol instance;
lock (minProtocolLock)
{
if (minProtocol != sender as IMINProtocol)
return;
instance = minProtocol;
}
if (instance == null)
return;
Task.Run(async () =>
{
await instance.Reset();
await instance.QueueFrame((byte)MassiveKnobFrameID.Handshake, new[] { (byte)'M', (byte)'K' });
});
}
private void MinProtocolOnOnFrame(object sender, MINFrameEventArgs e)
{
IMINProtocol instance;
lock (minProtocolLock)
{
if (minProtocol != sender as IMINProtocol)
return;
instance = minProtocol;
}
if (instance == null)
return;
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault - by design
switch ((MassiveKnobFrameID)e.Id)
{
case MassiveKnobFrameID.HandshakeResponse:
if (e.Payload.Length < 4)
{
logger.LogError("Invalid handshake response length, expected 4, got {length}: {payload}",
e.Payload.Length, BitConverter.ToString(e.Payload));
Disconnect();
return;
}
var specs = new DeviceSpecs(e.Payload[0], e.Payload[1], e.Payload[2], e.Payload[3]);
context.Connected(specs);
break;
case MassiveKnobFrameID.AnalogInput:
if (e.Payload.Length < 2)
{
logger.LogError("Invalid analog input payload length, expected 2, got {length}: {payload}",
e.Payload.Length, BitConverter.ToString(e.Payload));
return;
}
context.AnalogChanged(e.Payload[0], e.Payload[1]);
break;
case MassiveKnobFrameID.DigitalInput:
if (e.Payload.Length < 2)
{
logger.LogError("Invalid digital input payload length, expected 2, got {length}: {payload}",
e.Payload.Length, BitConverter.ToString(e.Payload));
return;
}
context.DigitalChanged(e.Payload[0], e.Payload[1] != 0);
break;
case MassiveKnobFrameID.Error:
logger.LogError("Error message received from device: {message}", Encoding.ASCII.GetString(e.Payload));
break;
default:
logger.LogWarning("Unknown frame ID received: {frameId}", e.Id);
break;
}
}
private void Disconnect()
{
lock (minProtocolLock)
{
minProtocol?.Dispose();
minProtocol = null;
}
context.Disconnected();
}
/*
void SafeCloseSerialPort()
{
try
{
serialPort?.Dispose();
}
catch
{
// ignored
}
serialPort = null;
context.Connecting();
}
while (serialPort == null && !cancellationToken.IsCancellationRequested)
{
if (!TryConnect(ref serialPort, settings, out specs))
{
SafeCloseSerialPort();
Thread.Sleep(500);
}
else
break;
}
if (cancellationToken.IsCancellationRequested)
{
SafeCloseSerialPort();
break;
}
var processingMessage = false;
Debug.Assert(serialPort != null, nameof(serialPort) + " != null");
serialPort.DataReceived += (sender, args) =>
{
if (args.EventType != SerialData.Chars || processingMessage)
return;
var senderPort = (SerialPort)sender;
processingMessage = true;
try
{
var message = (char)senderPort.ReadByte();
ProcessMessage(senderPort, message);
}
finally
{
processingMessage = false;
}
};
context.Connected(specs);
try
{
// This is where sending data to the hardware would be implemented
while (serialPort.IsOpen && !cancellationToken.IsCancellationRequested)
{
Thread.Sleep(10);
}
}
catch
{
// ignored
}
context.Disconnected();
SafeCloseSerialPort();
if (!cancellationToken.IsCancellationRequested)
Thread.Sleep(500);
}
}
private static bool TryConnect(ref SerialPort serialPort, ConnectionSettings settings, out DeviceSpecs specs)
{
try
{
serialPort = new SerialPort(settings.PortName, settings.BaudRate)
{
Encoding = Encoding.ASCII,
ReadTimeout = 1000,
WriteTimeout = 1000,
DtrEnable = settings.DtrEnable
};
serialPort.Open();
// Send handshake
serialPort.Write(new[] { 'H', 'M', 'K', 'B' }, 0, 4);
// Wait for reply
var response = serialPort.ReadByte();
if ((char) response == 'H')
{
specs = new DeviceSpecs(serialPort.ReadByte(), serialPort.ReadByte(), serialPort.ReadByte(), serialPort.ReadByte());
if (specs.AnalogInputCount > -1 && specs.DigitalInputCount > -1 && specs.AnalogOutputCount > -1 && specs.DigitalOutputCount > -1)
return true;
}
else
CheckForError(serialPort, (char)response);
specs = default;
return false;
}
catch
{
specs = default;
return false;
}
}
private void ProcessMessage(SerialPort serialPort, char message)
{
switch (message)
{
case 'V':
var knobIndex = (byte)serialPort.ReadByte();
var volume = (byte)serialPort.ReadByte();
if (knobIndex < 255 && volume <= 100)
context.AnalogChanged(knobIndex, volume);
break;
}
}
private static void CheckForError(SerialPort serialPort, char message)
{
if (message != 'E')
return;
var length = serialPort.ReadByte();
if (length <= 0)
return;
var buffer = new byte[length];
var bytesRead = 0;
while (bytesRead < length)
bytesRead += serialPort.Read(buffer, bytesRead, length - bytesRead);
var errorMessage = Encoding.ASCII.GetString(buffer);
Debug.Print(errorMessage);
}
private readonly struct ConnectionSettings
{
public readonly string PortName;
public readonly int BaudRate;
public readonly bool DtrEnable;
public ConnectionSettings(string portName, int baudRate, bool dtrEnable)
{
PortName = portName;
BaudRate = baudRate;
DtrEnable = dtrEnable;
}
}
*/
}
}

View File

@ -0,0 +1,13 @@
using Voicemeeter;
namespace MassiveKnob.Plugin.VoiceMeeter.Base
{
public class BaseVoiceMeeterSettings
{
public RunVoicemeeterParam Version
{
get => InstanceRegister.Version;
set => InstanceRegister.Version = value;
}
}
}

View File

@ -0,0 +1,15 @@
<UserControl x:Class="MassiveKnob.Plugin.VoiceMeeter.Base.BaseVoiceMeeterSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:base="clr-namespace:MassiveKnob.Plugin.VoiceMeeter.Base"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.VoiceMeeter"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance base:BaseVoiceMeeterSettingsViewModel}">
<StackPanel Orientation="Vertical">
<TextBlock Text="{x:Static coreAudio:Strings.SettingVoiceMeeterVersion}" />
<ComboBox Margin="0,4,0,0" ItemsSource="{Binding Versions}" SelectedItem="{Binding SelectedVersion}" DisplayMemberPath="DisplayName" />
</StackPanel>
</UserControl>

View File

@ -0,0 +1,13 @@
namespace MassiveKnob.Plugin.VoiceMeeter.Base
{
/// <summary>
/// Interaction logic for BaseVoiceMeeterSettingsView.xaml
/// </summary>
public partial class BaseVoiceMeeterSettingsView
{
public BaseVoiceMeeterSettingsView()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,84 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Voicemeeter;
namespace MassiveKnob.Plugin.VoiceMeeter.Base
{
public class BaseVoiceMeeterSettingsViewModel<T> : BaseVoiceMeeterSettingsViewModel where T : BaseVoiceMeeterSettings
{
protected new T Settings => (T)base.Settings;
public BaseVoiceMeeterSettingsViewModel(T settings) : base(settings)
{
}
}
public class BaseVoiceMeeterSettingsViewModel : INotifyPropertyChanged
{
protected readonly BaseVoiceMeeterSettings Settings;
public event PropertyChangedEventHandler PropertyChanged;
// ReSharper disable UnusedMember.Global - used by WPF Binding
public IList<VoiceMeeterVersionViewModel> Versions { get; }
private VoiceMeeterVersionViewModel selectedVersion;
public VoiceMeeterVersionViewModel SelectedVersion
{
get => selectedVersion;
set
{
if (value == selectedVersion)
return;
selectedVersion = value;
OnPropertyChanged();
Settings.Version = value?.Version ?? RunVoicemeeterParam.None;
}
}
// ReSharper restore UnusedMember.Global
public BaseVoiceMeeterSettingsViewModel(BaseVoiceMeeterSettings settings)
{
Settings = settings;
Versions = new List<VoiceMeeterVersionViewModel>
{
new VoiceMeeterVersionViewModel(RunVoicemeeterParam.Voicemeeter, "VoiceMeeter Standard"),
new VoiceMeeterVersionViewModel(RunVoicemeeterParam.VoicemeeterBanana, "VoiceMeeter Banana"),
new VoiceMeeterVersionViewModel(RunVoicemeeterParam.VoicemeeterPotato, "VoiceMeeter Potato")
};
}
public virtual bool IsSettingsProperty(string propertyName)
{
// SelectedVersion already trigger a VoiceMeeterVersionChanged for all instances,
// which causes the settings to be stored
return propertyName != nameof(Versions) && propertyName != nameof(SelectedVersion);
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public class VoiceMeeterVersionViewModel
{
public RunVoicemeeterParam Version { get; }
public string DisplayName { get; }
public VoiceMeeterVersionViewModel(RunVoicemeeterParam version, string displayName)
{
Version = version;
DisplayName = displayName;
}
}
}

View File

@ -0,0 +1,139 @@
using System;
using System.Threading.Tasks;
using System.Windows.Controls;
using Microsoft.Extensions.Logging;
using Voicemeeter;
namespace MassiveKnob.Plugin.VoiceMeeter.GetParameter
{
public class VoiceMeeterGetParameterAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("4904fffb-aaec-4f19-88bb-49f6ed38c3ec");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.OutputDigital;
public string Name { get; } = Strings.GetParameterName;
public string Description { get; } = Strings.GetParameterDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobActionInstance, IVoiceMeeterAction
{
private IMassiveKnobActionContext actionContext;
private VoiceMeeterGetParameterActionSettings settings;
private Parameters parameters;
private IDisposable parameterChanged;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<VoiceMeeterGetParameterActionSettings>();
ApplySettings();
InstanceRegister.Register(this);
}
public void Dispose()
{
InstanceRegister.Unregister(this);
parameterChanged?.Dispose();
parameters?.Dispose();
}
private void ApplySettings()
{
if (InstanceRegister.Version == RunVoicemeeterParam.None)
return;
if (parameters == null)
parameters = new Parameters();
if (string.IsNullOrEmpty(settings.Parameter))
{
parameterChanged?.Dispose();
parameterChanged = null;
}
if (parameterChanged == null)
parameterChanged = parameters.Subscribe(x => ParametersChanged());
// TODO directly update output depending on value
/*
if (playbackDevice != null)
actionContext.SetDigitalOutput(settings.Inverted ? !playbackDevice.IsMuted : playbackDevice.IsMuted);
*/
}
public UserControl CreateSettingsControl()
{
var viewModel = new VoiceMeeterGetParameterActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new VoiceMeeterGetParameterActionSettingsView(viewModel);
}
public void VoiceMeeterVersionChanged()
{
// TODO update viewModel
// TODO reset parameterChanged subscription
actionContext.SetSettings(settings);
}
private void ParametersChanged()
{
if (InstanceRegister.Version == RunVoicemeeterParam.None || string.IsNullOrEmpty(settings.Parameter))
return;
// TODO if another task is already running, wait / chain
// TODO only start task if not yet initialized
Task.Run(async () =>
{
await InstanceRegister.InitializeVoicemeeter();
bool on;
if (float.TryParse(settings.Value, out var settingsFloatValue))
{
try
{
// Even on/off values are returned as floating point "1.000" in text form,
// so try to compare in native format first
var floatValue = global::VoiceMeeter.Remote.GetParameter(settings.Parameter);
on = Math.Abs(settingsFloatValue - floatValue) < 0.001;
}
catch
{
// Fall back to text comparison
var value = global::VoiceMeeter.Remote.GetTextParameter(settings.Parameter);
on = string.Equals(value, settings.Value, StringComparison.InvariantCultureIgnoreCase);
}
}
else
{
var value = global::VoiceMeeter.Remote.GetTextParameter(settings.Parameter);
on = string.Equals(value, settings.Value, StringComparison.InvariantCultureIgnoreCase);
}
// TODO check specific parameter for changes, not just any parameter
actionContext.SetDigitalOutput(settings.Inverted ? !on : on);
});
}
}
}
}

View File

@ -0,0 +1,11 @@
using MassiveKnob.Plugin.VoiceMeeter.Base;
namespace MassiveKnob.Plugin.VoiceMeeter.GetParameter
{
public class VoiceMeeterGetParameterActionSettings : BaseVoiceMeeterSettings
{
public string Parameter { get; set; }
public string Value { get; set; }
public bool Inverted { get; set; }
}
}

View File

@ -0,0 +1,25 @@
<UserControl x:Class="MassiveKnob.Plugin.VoiceMeeter.GetParameter.VoiceMeeterGetParameterActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:getParameter="clr-namespace:MassiveKnob.Plugin.VoiceMeeter.GetParameter"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.VoiceMeeter"
xmlns:base="clr-namespace:MassiveKnob.Plugin.VoiceMeeter.Base"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance getParameter:VoiceMeeterGetParameterActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseVoiceMeeterSettingsView />
<TextBlock Margin="0,24,0,0" Text="{x:Static coreAudio:Strings.SettingGetParameterParameter}" />
<TextBox Margin="0,4,0,0" Text="{Binding Parameter}" />
<TextBlock Margin="0,8,0,0" Text="{x:Static coreAudio:Strings.SettingGetParameterValue}" />
<TextBox Margin="0,4,0,0" Text="{Binding Value}" />
<CheckBox Margin="0,8,0,0" IsChecked="{Binding Inverted}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingGetParameterInverted}" />
</CheckBox>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.VoiceMeeter.GetParameter
{
/// <summary>
/// Interaction logic for VoiceMeeterGetParameterActionSettingsView.xaml
/// </summary>
public partial class VoiceMeeterGetParameterActionSettingsView
{
public VoiceMeeterGetParameterActionSettingsView(VoiceMeeterGetParameterActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,54 @@
using MassiveKnob.Plugin.VoiceMeeter.Base;
namespace MassiveKnob.Plugin.VoiceMeeter.GetParameter
{
public class VoiceMeeterGetParameterActionSettingsViewModel : BaseVoiceMeeterSettingsViewModel<VoiceMeeterGetParameterActionSettings>
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public string Parameter
{
get => Settings.Parameter;
set
{
if (value == Settings.Parameter)
return;
Settings.Parameter = value;
OnPropertyChanged();
}
}
public string Value
{
get => Settings.Value;
set
{
if (value == Settings.Value)
return;
Settings.Value = value;
OnPropertyChanged();
}
}
public bool Inverted
{
get => Settings.Inverted;
set
{
if (value == Settings.Inverted)
return;
Settings.Inverted = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
// ReSharper disable once SuggestBaseTypeForParameter - by design
public VoiceMeeterGetParameterActionSettingsViewModel(VoiceMeeterGetParameterActionSettings settings) : base(settings)
{
}
}
}

View File

@ -0,0 +1,7 @@
namespace MassiveKnob.Plugin.VoiceMeeter
{
public interface IVoiceMeeterAction
{
void VoiceMeeterVersionChanged();
}
}

View File

@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Voicemeeter;
namespace MassiveKnob.Plugin.VoiceMeeter
{
public static class InstanceRegister
{
private static readonly object InstancesLock = new object();
private static readonly HashSet<IVoiceMeeterAction> Instances = new HashSet<IVoiceMeeterAction>();
private static Task initializeTask;
// The VoiceMeeter Remote only connects to one instance, so all actions need to be in sync
private static RunVoicemeeterParam version;
public static RunVoicemeeterParam Version
{
get => version;
set
{
if (value == version)
return;
version = value;
Notify(action => action.VoiceMeeterVersionChanged());
initializeTask = Task.Run(async () =>
{
await global::VoiceMeeter.Remote.Initialize(version);
});
}
}
public static Task InitializeVoicemeeter()
{
return initializeTask ?? Task.CompletedTask;
}
public static void Register(IVoiceMeeterAction instance)
{
lock (InstancesLock)
{
Instances.Add(instance);
}
}
public static void Unregister(IVoiceMeeterAction instance)
{
lock (InstancesLock)
{
Instances.Remove(instance);
}
}
public static void Notify(Action<IVoiceMeeterAction> action)
{
lock (InstancesLock)
{
foreach (var instance in Instances)
action(instance);
}
}
}
}

View File

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{19533600-D4F6-4BD4-82A3-C0234FDF044C}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>MassiveKnob.Plugin.VoiceMeeter</RootNamespace>
<AssemblyName>MassiveKnob.Plugin.VoiceMeeter</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>$(localappdata)\MassiveKnob\Plugins\VoiceMeeter\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xaml" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="Base\BaseVoiceMeeterSettings.cs" />
<Compile Include="Base\BaseVoiceMeeterSettingsView.xaml.cs">
<DependentUpon>BaseVoiceMeeterSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="Base\BaseVoiceMeeterSettingsViewModel.cs" />
<Compile Include="GetParameter\VoiceMeeterGetParameterAction.cs" />
<Compile Include="GetParameter\VoiceMeeterGetParameterActionSettings.cs" />
<Compile Include="GetParameter\VoiceMeeterGetParameterActionSettingsView.xaml.cs">
<DependentUpon>VoiceMeeterGetParameterActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="GetParameter\VoiceMeeterGetParameterActionSettingsViewModel.cs" />
<Compile Include="InstanceRegister.cs" />
<Compile Include="IVoiceMeeterAction.cs" />
<Compile Include="MassiveKnobVoiceMeeterPlugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="RunMacro\VoiceMeeterRunMacroAction.cs" />
<Compile Include="RunMacro\VoiceMeeterRunMacroActionSettings.cs" />
<Compile Include="RunMacro\VoiceMeeterRunMacroActionSettingsView.xaml.cs">
<DependentUpon>VoiceMeeterRunMacroActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="RunMacro\VoiceMeeterRunMacroActionSettingsViewModel.cs" />
<Compile Include="Strings.Designer.cs">
<DependentUpon>Strings.resx</DependentUpon>
<AutoGen>True</AutoGen>
<DesignTime>True</DesignTime>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Strings.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Strings.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MassiveKnob.Plugin\MassiveKnob.Plugin.csproj">
<Project>{A1298BE4-1D23-416C-8C56-FC9264487A95}</Project>
<Name>MassiveKnob.Plugin</Name>
</ProjectReference>
<ProjectReference Include="..\VoicemeeterRemote\Voicemeeter\Voicemeeter.csproj">
<Project>{f35dd8e5-91fa-403e-b6f6-8d2b4ae84198}</Project>
<Name>Voicemeeter</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Include="MassiveKnobPlugin.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Page Include="Base\BaseVoiceMeeterSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="GetParameter\VoiceMeeterGetParameterActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="RunMacro\VoiceMeeterRunMacroActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@ -0,0 +1,3 @@
{
"EntryAssembly": "MassiveKnob.Plugin.VoiceMeeter.dll"
}

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using MassiveKnob.Plugin.VoiceMeeter.GetParameter;
using MassiveKnob.Plugin.VoiceMeeter.RunMacro;
namespace MassiveKnob.Plugin.VoiceMeeter
{
[MassiveKnobPlugin]
public class MassiveKnobVoiceMeeterPlugin : IMassiveKnobActionPlugin
{
public Guid PluginId { get; } = new Guid("cf6634f1-97e3-4a18-a4aa-289b558c0e82");
public string Name { get; } = Strings.PluginName;
public string Description { get; } = Strings.PluginDescription;
public string Author { get; } = "Mark van Renswoude <mark@x2software.net>";
public string Url { get; } = "https://www.github.com/MvRens/MassiveKnob/";
public IEnumerable<IMassiveKnobAction> Actions { get; } = new IMassiveKnobAction[]
{
new VoiceMeeterRunMacroAction(),
new VoiceMeeterGetParameterAction()
};
}
}

Some files were not shown because too many files have changed in this diff Show More