WIP: Add channels to MIDI playback

pull/50/head
James Ball 2021-12-31 15:31:02 +00:00 zatwierdzone przez James H Ball
rodzic 0afd92009c
commit df9ec401e4
4 zmienionych plików z 162 dodań i 67 usunięć

Wyświetl plik

@ -22,6 +22,8 @@ import javax.sound.midi.ShortMessage;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import static sh.ball.gui.Gui.audioPlayer;
public class ShapeAudioPlayer implements AudioPlayer<List<Shape>> {
private static final double MIN_TRACE = 0.001;
@ -35,13 +37,13 @@ public class ShapeAudioPlayer implements AudioPlayer<List<Shape>> {
// Stereo audio
private static final int NUM_OUTPUTS = 2;
private static final double MIN_LENGTH_INCREMENT = 0.0000000001;
public static final double EPSILON = 0.001;
// MIDI
private final short[] keyTargetVolumes = new short[128];
private final short[] keyActualVolumes = new short[128];
private final Set<Integer> keysDown = ConcurrentHashMap.newKeySet();
private final short[][] keyTargetVolumes = new short[16][128];
private final short[][] keyActualVolumes = new short[16][128];
private final Set<MidiNote> keysDown = ConcurrentHashMap.newKeySet();
private boolean midiStarted = false;
private int mainChannel = 0;
private int baseKey = 60;
private double pitchBend = 1.0;
private int lastDecay = 0;
@ -54,7 +56,7 @@ public class ShapeAudioPlayer implements AudioPlayer<List<Shape>> {
private final Callable<AudioEngine> audioEngineBuilder;
private final BlockingQueue<List<Shape>> frameQueue = new ArrayBlockingQueue<>(BUFFER_SIZE);
private final Map<Object, Effect> effects = new ConcurrentHashMap<>();
private final List<SineEffect> sineEffects = new CopyOnWriteArrayList<>();
private final Map<MidiNote, SineEffect> sineEffects = new ConcurrentHashMap<>();
private final List<Listener> listeners = new CopyOnWriteArrayList<>();
private AudioEngine audioEngine;
@ -89,13 +91,25 @@ public class ShapeAudioPlayer implements AudioPlayer<List<Shape>> {
}
private void resetMidi() {
keysDown.clear();
for (int i = 0; i < keyTargetVolumes.length; i++) {
Arrays.fill(keyTargetVolumes[i], (short) 0);
Arrays.fill(keyActualVolumes[i], (short) 0);
}
// Middle C is down by default
keyTargetVolumes[60] = (short) MidiNote.MAX_VELOCITY;
keyActualVolumes[60] = (short) MidiNote.MAX_VELOCITY;
keysDown.add(60);
keyTargetVolumes[0][60] = (short) MidiNote.MAX_VELOCITY;
keyActualVolumes[0][60] = (short) MidiNote.MAX_VELOCITY;
keysDown.add(new MidiNote(60));
midiStarted = false;
}
public void stopMidiNotes() {
keysDown.clear();
for (short[] keyTargetVolume : keyTargetVolumes) {
Arrays.fill(keyTargetVolume, (short) 0);
}
}
public void setFrequency(DoubleProperty frequency) {
this.frequency = frequency;
frequency.addListener((o, old, f) -> setBaseFrequency(f.doubleValue()));
@ -192,12 +206,14 @@ public class ShapeAudioPlayer implements AudioPlayer<List<Shape>> {
}
private Vector2 applyEffects(int frame, Vector2 vector) {
vector = vector.scale(2 * baseFrequencyVolumeScale * keyActualVolumes[baseKey] / MidiNote.MAX_VELOCITY);
vector = vector.scale(2 * baseFrequencyVolumeScale * keyActualVolumes[mainChannel][baseKey] / MidiNote.MAX_VELOCITY);
if (midiStarted) {
if (lastDecay > decayFrames) {
for (int i = 0; i < keyActualVolumes.length; i++) {
if (keyActualVolumes[i] > keyTargetVolumes[i]) {
keyActualVolumes[i]--;
for (int j = 0; j < keyActualVolumes[i].length; j++) {
if (keyActualVolumes[i][j] > keyTargetVolumes[i][j]) {
keyActualVolumes[i][j]--;
}
}
}
lastDecay = 0;
@ -205,17 +221,18 @@ public class ShapeAudioPlayer implements AudioPlayer<List<Shape>> {
lastDecay++;
if (lastAttack > attackFrames) {
for (int i = 0; i < keyActualVolumes.length; i++) {
if (keyActualVolumes[i] < keyTargetVolumes[i]) {
keyActualVolumes[i]++;
for (int j = 0; j < keyActualVolumes[i].length; j++) {
if (keyActualVolumes[i][j] < keyTargetVolumes[i][j]) {
keyActualVolumes[i][j]++;
}
}
}
lastAttack = 0;
}
lastAttack++;
for (int key : keysDown) {
double frequency = new MidiNote(key).frequency();
if (Math.abs(frequency - baseFrequency) > EPSILON) {
vector = sineEffects.get(key).apply(frame, vector);
for (MidiNote note : keysDown) {
if (note.channel() != mainChannel) {
vector = sineEffects.get(note).apply(frame, vector);
}
}
}
@ -331,6 +348,11 @@ public class ShapeAudioPlayer implements AudioPlayer<List<Shape>> {
updateSineEffects();
}
public void setMainMidiChannel(int channel) {
this.mainChannel = channel;
notesChanged();
}
@Override
public void addFrame(List<Shape> frame) {
try {
@ -371,8 +393,11 @@ public class ShapeAudioPlayer implements AudioPlayer<List<Shape>> {
this.device = device;
sineEffects.clear();
this.sampleRate = device.sampleRate();
for (int i = 0; i < keyActualVolumes.length; i++) {
sineEffects.add(new SineEffect(sampleRate, new MidiNote(i).frequency(), keyActualVolumes[i] / MidiNote.MAX_VELOCITY));
for (int channel = 0; channel < keyActualVolumes.length; channel++) {
for (int key = 0; key < keyActualVolumes[channel].length; key++) {
MidiNote note = new MidiNote(key, channel);
sineEffects.put(note, new SineEffect(sampleRate, note.frequency(), keyActualVolumes[channel][key] / MidiNote.MAX_VELOCITY));
}
}
for (Effect effect : effects.values()) {
if (effect instanceof PhaseEffect phase) {
@ -422,35 +447,37 @@ public class ShapeAudioPlayer implements AudioPlayer<List<Shape>> {
private void updateSineEffects() {
double totalVolume = 0;
for (short volume : keyActualVolumes) {
totalVolume += volume / MidiNote.MAX_VELOCITY;
for (short[] volumes : keyActualVolumes) {
for (short volume : volumes) {
totalVolume += volume / MidiNote.MAX_VELOCITY;
}
}
if (totalVolume != 0) {
double scaledVolume = (1 - baseFrequencyVolumeScale) / totalVolume;
for (int i = 0; i < keyActualVolumes.length; i++) {
double frequency = new MidiNote(i).frequency();
if (keyActualVolumes[i] > 0 && sineEffects.size() != 0) {
SineEffect effect = sineEffects.get(i);
effect.setVolume(scaledVolume * keyActualVolumes[i] / MidiNote.MAX_VELOCITY);
effect.setFrequency(frequency * pitchBend);
for (int channel = 0; channel < keyActualVolumes.length; channel++) {
for (int key = 0; key < keyActualVolumes[channel].length; key++) {
MidiNote note = new MidiNote(key, channel);
if (keyActualVolumes[channel][key] > 0 && sineEffects.size() != 0) {
SineEffect effect = sineEffects.get(note);
effect.setVolume(scaledVolume * keyActualVolumes[channel][key] / MidiNote.MAX_VELOCITY);
effect.setFrequency(note.frequency() * pitchBend);
}
}
}
}
}
private void notesChanged() {
int loudestKey = 0;
int maxVelocity = 0;
for (int i = 0; i < keyTargetVolumes.length; i++) {
if (keyTargetVolumes[i] > maxVelocity && keysDown.contains(i)) {
loudestKey = i;
maxVelocity = keyTargetVolumes[i];
MidiNote note = null;
for (int key = 0; key < keyTargetVolumes[mainChannel].length; key++) {
note = new MidiNote(key, mainChannel);
if (keysDown.contains(note)) {
break;
}
}
if (maxVelocity > 0) {
double baseFrequency = new MidiNote(loudestKey).frequency();
frequency.set(baseFrequency);
baseKey = loudestKey;
if (note != null) {
frequency.set(note.frequency());
baseKey = note.key();
}
updateSineEffects();
@ -472,17 +499,30 @@ public class ShapeAudioPlayer implements AudioPlayer<List<Shape>> {
keysDown.clear();
midiStarted = true;
}
MidiNote note = new MidiNote(message.getData1());
MidiNote note = new MidiNote(message.getData1(), message.getChannel());
int velocity = message.getData2();
if (command == ShortMessage.NOTE_OFF) {
keyTargetVolumes[note.key()] = 0;
keysDown.remove(note.key());
keyTargetVolumes[note.channel()][note.key()] = 0;
keysDown.remove(note);
} else {
keyTargetVolumes[note.key()] = (short) velocity;
keysDown.add(note.key());
keyTargetVolumes[note.channel()][note.key()] = (short) velocity;
keysDown.add(note);
}
notesChanged();
} else if (command == ShortMessage.PITCH_BEND) {
// using these instructions https://sites.uci.edu/camp2014/2014/04/30/managing-midi-pitchbend-messages/
int pitchBend = (message.getData2() << MidiNote.PITCH_BEND_DATA_LENGTH) | message.getData1();
// get pitch bend in range -1 to 1
double pitchBendFactor = (double) pitchBend / MidiNote.PITCH_BEND_MAX;
pitchBendFactor = 2 * pitchBendFactor - 1;
pitchBendFactor *= MidiNote.PITCH_BEND_SEMITONES;
// 12 tone equal temperament
pitchBendFactor /= 12;
pitchBendFactor = Math.pow(2, pitchBendFactor);
audioPlayer.setPitchBendFactor(pitchBendFactor);
}
}

Wyświetl plik

@ -5,6 +5,9 @@ import java.util.Objects;
public class MidiNote {
public static double MAX_VELOCITY = 127;
public static short MAX_CC = 0x78;
public static short MAX_CHANNEL = 15;
public static short ALL_NOTES_OFF = 0x7B;
public static final double MIDDLE_C = 261.6255798;
public static final int PITCH_BEND_DATA_LENGTH = 7;
public static final int PITCH_BEND_MAX = 16383;
@ -19,14 +22,20 @@ public class MidiNote {
private final String name;
private final int key;
private final int octave;
private final int channel;
public MidiNote(int key) {
public MidiNote(int key, int channel) {
this.key = key;
this.channel = channel;
this.octave = (key / 12)-1;
int note = key % 12;
this.name = NOTE_NAMES[note];
}
public MidiNote(int key) {
this(key, 0);
}
public int key() {
return key;
}
@ -36,17 +45,21 @@ public class MidiNote {
return (float) (A4 * Math.pow(2, (key - KEY_A4) / 12d));
}
public int channel() {
return channel;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MidiNote midiNote = (MidiNote) o;
return key == midiNote.key;
return key == midiNote.key && channel == midiNote.channel;
}
@Override
public int hashCode() {
return Objects.hash(key);
return Objects.hash(key, channel);
}
@Override

Wyświetl plik

@ -5,6 +5,7 @@ import javafx.scene.control.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.SVGPath;
import javafx.util.converter.IntegerStringConverter;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
@ -12,8 +13,11 @@ import sh.ball.audio.*;
import java.io.*;
import java.net.URL;
import java.text.NumberFormat;
import java.text.ParsePosition;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.UnaryOperator;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
@ -88,6 +92,12 @@ public class MainController implements Initializable, FrequencyListener, MidiLis
private MenuItem saveProjectMenuItem;
@FXML
private MenuItem saveAsProjectMenuItem;
@FXML
private MenuItem resetMidiMappingMenuItem;
@FXML
private MenuItem stopMidiNotesMenuItem;
@FXML
private Spinner<Integer> midiChannelSpinner;
public MainController() throws Exception {
// Clone DEFAULT_OBJ InputStream using a ByteArrayOutputStream
@ -107,9 +117,9 @@ public class MainController implements Initializable, FrequencyListener, MidiLis
}
this.sampleRate = defaultDevice.sampleRate();
}
// initialises midiButtonMap by mapping MIDI logo SVGs to the slider that they
// control if they are selected.
private Map<SVGPath, Slider> initializeMidiButtonMap() {
Map<SVGPath, Slider> midiMap = new HashMap<>();
subControllers().forEach(controller -> midiMap.putAll(controller.getMidiButtonMap()));
@ -177,6 +187,29 @@ public class MainController implements Initializable, FrequencyListener, MidiLis
}
});
resetMidiMappingMenuItem.setOnAction(e -> resetCCMap());
stopMidiNotesMenuItem.setOnAction(e -> audioPlayer.stopMidiNotes());
NumberFormat format = NumberFormat.getIntegerInstance();
UnaryOperator<TextFormatter.Change> filter = c -> {
if (c.isContentChange()) {
ParsePosition parsePosition = new ParsePosition(0);
// NumberFormat evaluates the beginning of the text
format.parse(c.getControlNewText(), parsePosition);
if (parsePosition.getIndex() == 0 ||
parsePosition.getIndex() < c.getControlNewText().length()) {
// reject parsing the complete text failed
return null;
}
}
return c;
};
midiChannelSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, MidiNote.MAX_CHANNEL));
midiChannelSpinner.getEditor().setTextFormatter(new TextFormatter<>(new IntegerStringConverter(), 0, filter));
midiChannelSpinner.valueProperty().addListener((o, oldValue, newValue) -> audioPlayer.setMainMidiChannel(newValue));
objController.updateObjectRotateSpeed();
switchAudioDevice(defaultDevice, false);
@ -388,7 +421,7 @@ public class MainController implements Initializable, FrequencyListener, MidiLis
}
// If there is a slider associated with the MIDI channel, update the value
// of it
if (CCMap.containsKey(cc)) {
if (cc <= MidiNote.MAX_CC && CCMap.containsKey(cc)) {
Slider slider = midiButtonMap.get(CCMap.get(cc));
double sliderValue = midiPressureToPressure(slider, value);
@ -397,23 +430,12 @@ public class MainController implements Initializable, FrequencyListener, MidiLis
sliderValue = increment * (Math.round(sliderValue / increment));
}
slider.setValue(sliderValue);
} else if (cc == MidiNote.ALL_NOTES_OFF) {
audioPlayer.stopMidiNotes();
}
} else if (command == ShortMessage.PROGRAM_CHANGE) {
// We want to change the file that is currently playing
Platform.runLater(() -> changeFrameSource(message.getMessage()[1]));
} else if (command == ShortMessage.PITCH_BEND) {
// using these instructions https://sites.uci.edu/camp2014/2014/04/30/managing-midi-pitchbend-messages/
int pitchBend = (message.getData2() << MidiNote.PITCH_BEND_DATA_LENGTH) | message.getData1();
// get pitch bend in range -1 to 1
double pitchBendFactor = (double) pitchBend / MidiNote.PITCH_BEND_MAX;
pitchBendFactor = 2 * pitchBendFactor - 1;
pitchBendFactor *= MidiNote.PITCH_BEND_SEMITONES;
// 12 tone equal temperament
pitchBendFactor /= 12;
pitchBendFactor = Math.pow(2, pitchBendFactor);
audioPlayer.setPitchBendFactor(pitchBendFactor);
}
}

Wyświetl plik

@ -1,24 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.CustomMenuItem?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Menu?>
<?import javafx.scene.control.MenuBar?>
<?import javafx.scene.control.MenuItem?>
<?import javafx.scene.control.Spinner?>
<?import javafx.scene.control.TitledPane?>
<?import javafx.scene.input.KeyCodeCombination?>
<?import javafx.scene.layout.AnchorPane?>
<AnchorPane prefHeight="658.0" prefWidth="837.0" stylesheets="@../css/main.css" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="sh.ball.gui.controller.MainController">
<TitledPane animated="false" collapsible="false" layoutX="424.0"
layoutY="38.0" prefHeight="387.0" prefWidth="402.0"
text="Audio Effects">
<fx:include fx:id="effects" source="effects.fxml"/>
<TitledPane animated="false" collapsible="false" layoutX="424.0" layoutY="38.0" prefHeight="387.0" prefWidth="402.0" text="Audio Effects">
<fx:include fx:id="effects" source="effects.fxml" />
</TitledPane>
<TitledPane fx:id="objTitledPane" animated="false" collapsible="false" layoutX="424.0" layoutY="436.0" maxHeight="-Infinity" maxWidth="-Infinity" prefHeight="213.0" prefWidth="402.0" text="3D .obj file settings">
<fx:include fx:id="obj" source="obj.fxml"/>
<fx:include fx:id="obj" source="obj.fxml" />
</TitledPane>
<fx:include fx:id="general" source="general.fxml"/>
<fx:include fx:id="general" source="general.fxml" />
<TitledPane collapsible="false" layoutX="10.0" layoutY="363.0" prefHeight="286.0" prefWidth="402.0" text="Image settings">
<fx:include fx:id="image" source="image.fxml"/>
<fx:include fx:id="image" source="image.fxml" />
</TitledPane>
<MenuBar prefHeight="27.0" prefWidth="838.0">
<menus>
@ -41,6 +42,25 @@
</MenuItem>
</items>
</Menu>
<Menu mnemonicParsing="false" text="MIDI">
<items>
<MenuItem fx:id="resetMidiMappingMenuItem" mnemonicParsing="false" text="Reset MIDI Mappings">
<accelerator>
<KeyCodeCombination alt="UP" code="M" control="UP" meta="UP" shift="UP" shortcut="DOWN" />
</accelerator></MenuItem>
<MenuItem fx:id="stopMidiNotesMenuItem" mnemonicParsing="false" text="Stop MIDI Notes" />
<CustomMenuItem hideOnClick="false" mnemonicParsing="false" text="MIDI Channel">
<content>
<AnchorPane>
<children>
<Label prefHeight="25.0" text="Main MIDI Channel" textFill="WHITE" />
<Spinner fx:id="midiChannelSpinner" editable="true" layoutY="25.0" />
</children>
</AnchorPane>
</content>
</CustomMenuItem>
</items>
</Menu>
</menus>
</MenuBar>
</AnchorPane>