Merge pull request #23 from jameshball/save-audio

Allow audio recording to a .wav file
pull/35/head
James H Ball 2021-05-19 20:37:27 +01:00 zatwierdzone przez GitHub
commit a1539eb6f8
12 zmienionych plików z 138 dodań i 62 usunięć

3
.gitignore vendored
Wyświetl plik

@ -6,3 +6,6 @@
# Ignore local binaries for xt-audio
/win32-x64
# Ignore any .wav file output
*.wav

Wyświetl plik

@ -6,7 +6,7 @@
<groupId>sh.ball</groupId>
<artifactId>osci-render</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<name>osci-render</name>

Wyświetl plik

@ -13,6 +13,7 @@ import xt.audio.Structs.XtFormat;
import xt.audio.Structs.XtMix;
import xt.audio.Structs.XtStreamParams;
import java.io.*;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
@ -21,16 +22,24 @@ import java.util.concurrent.BlockingQueue;
import sh.ball.shapes.Shape;
import sh.ball.shapes.Vector2;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import java.util.List;
public class AudioPlayer implements Renderer<List<Shape>> {
public class AudioPlayer implements Renderer<List<Shape>, AudioInputStream> {
private static final int BUFFER_SIZE = 20;
private static final int BITS_PER_SAMPLE = 16;
private static final boolean SIGNED = true;
private static final boolean BIG_ENDIAN = false;
private final XtFormat format;
private final BlockingQueue<List<Shape>> frameQueue = new ArrayBlockingQueue<>(BUFFER_SIZE);
private final Map<Object, Effect> effects = new HashMap<>();
private ByteArrayOutputStream outputStream;
private boolean recording = false;
private int framesRecorded = 0;
private List<Shape> frame;
private int currentShape = 0;
private int audioFramesDrawn = 0;
@ -45,22 +54,35 @@ public class AudioPlayer implements Renderer<List<Shape>> {
this.format = new XtFormat(mix, channels);
}
private int render(XtStream stream, XtBuffer buffer, Object user) throws InterruptedException {
private int render(XtStream stream, XtBuffer buffer, Object user) throws InterruptedException, IOException {
XtSafeBuffer safe = XtSafeBuffer.get(stream);
safe.lock(buffer);
float[] output = (float[]) safe.getOutput();
for (int f = 0; f < buffer.frames; f++) {
Shape shape = getCurrentShape();
shape = shape.setWeight(weight);
Shape shape = getCurrentShape().setWeight(weight);
double totalAudioFrames = shape.getWeight() * shape.getLength();
double drawingProgress = totalAudioFrames == 0 ? 1 : audioFramesDrawn / totalAudioFrames;
Vector2 nextVector = applyEffects(f, shape.nextVector(drawingProgress));
output[f * format.channels.outputs] = (float) nextVector.getX();
output[f * format.channels.outputs + 1] = (float) nextVector.getY();
float nextX = cutoff((float) nextVector.getX());
float nextY = cutoff((float) nextVector.getY());
output[f * format.channels.outputs] = nextX;
output[f * format.channels.outputs + 1] = nextY;
if (recording) {
int left = (int)(nextX * Short.MAX_VALUE);
int right = (int)(nextY * Short.MAX_VALUE);
outputStream.write((byte) left);
outputStream.write((byte)(left >> 8));
outputStream.write((byte) right);
outputStream.write((byte)(right >> 8));
framesRecorded++;
}
audioFramesDrawn++;
@ -78,6 +100,15 @@ public class AudioPlayer implements Renderer<List<Shape>> {
return 0;
}
private float cutoff(float value) {
if (value < -1) {
return -1;
} else if (value > 1) {
return 1;
}
return value;
}
private Vector2 applyEffects(int frame, Vector2 vector) {
for (Effect effect : effects.values()) {
vector = effect.apply(frame, vector);
@ -163,4 +194,21 @@ public class AudioPlayer implements Renderer<List<Shape>> {
effects.remove(identifier);
}
@Override
public void startRecord() {
outputStream = new ByteArrayOutputStream();
framesRecorded = 0;
recording = true;
}
@Override
public AudioInputStream stopRecord() {
recording = false;
byte[] input = outputStream.toByteArray();
outputStream = null;
AudioFormat audioFormat = new AudioFormat(format.mix.rate, BITS_PER_SAMPLE, format.channels.outputs, SIGNED, BIG_ENDIAN);
return new AudioInputStream(new ByteArrayInputStream(input), audioFormat, framesRecorded);
}
}

Wyświetl plik

@ -1,13 +1,13 @@
package sh.ball.audio;
public class FrameProducer<T> implements Runnable {
public class FrameProducer<S, T> implements Runnable {
private final Renderer<T> renderer;
private final FrameSet<T> frames;
private final Renderer<S, T> renderer;
private final FrameSet<S> frames;
private boolean running;
public FrameProducer(Renderer<T> renderer, FrameSet<T> frames) {
public FrameProducer(Renderer<S, T> renderer, FrameSet<S> frames) {
this.renderer = renderer;
this.frames = frames;
}
@ -24,14 +24,7 @@ public class FrameProducer<T> implements Runnable {
running = false;
}
public Object setFrameSettings(Object settings) {
return setFrameSettings(settings, false);
}
public Object setFrameSettings(Object settings, boolean flushFrames) {
if (flushFrames) {
renderer.flushFrames();
}
return frames.setFrameSettings(settings);
public void setFrameSettings(Object settings) {
frames.setFrameSettings(settings);
}
}

Wyświetl plik

@ -4,5 +4,5 @@ public interface FrameSet<T> {
T next();
Object setFrameSettings(Object settings);
void setFrameSettings(Object settings);
}

Wyświetl plik

@ -2,17 +2,21 @@ package sh.ball.audio;
import sh.ball.audio.effect.Effect;
public interface Renderer<T> extends Runnable {
public interface Renderer<S, T> extends Runnable {
void stop();
void setQuality(double quality);
void addFrame(T frame);
void addFrame(S frame);
void flushFrames();
void addEffect(Object identifier, Effect effect);
void removeEffect(Object identifier);
void startRecord();
T stopRecord();
}

Wyświetl plik

@ -13,6 +13,8 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.ResourceBundle;
@ -26,12 +28,14 @@ import javafx.fxml.Initializable;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.xml.parsers.ParserConfigurationException;
import org.xml.sax.SAXException;
import sh.ball.audio.effect.TranslateEffect;
import sh.ball.engine.Vector3;
import sh.ball.parser.obj.ObjFrameSettings;
import sh.ball.parser.obj.ObjSettingsFactory;
import sh.ball.parser.obj.ObjParser;
import sh.ball.parser.ParserFactory;
@ -45,14 +49,15 @@ public class Controller implements Initializable {
private static final double DEFAULT_ROTATE_SPEED = 0.1;
private final FileChooser fileChooser = new FileChooser();
private final Renderer<List<Shape>> renderer;
private final Renderer<List<Shape>, AudioInputStream> renderer;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final RotateEffect rotateEffect = new RotateEffect(SAMPLE_RATE);
private final TranslateEffect translateEffect = new TranslateEffect(SAMPLE_RATE);
private final ScaleEffect scaleEffect = new ScaleEffect();
private FrameProducer<List<Shape>> producer;
private FrameProducer<List<Shape>, AudioInputStream> producer;
private boolean recording = false;
private Stage stage;
@ -61,6 +66,10 @@ public class Controller implements Initializable {
@FXML
private Label fileLabel;
@FXML
private Button recordButton;
@FXML
private Label recordLabel;
@FXML
private TextField translationXTextField;
@FXML
private TextField translationYTextField;
@ -101,7 +110,7 @@ public class Controller implements Initializable {
@FXML
private Slider bitCrushSlider;
public Controller(Renderer<List<Shape>> renderer) throws IOException {
public Controller(Renderer<List<Shape>, AudioInputStream> renderer) throws IOException {
this.renderer = renderer;
this.producer = new FrameProducer<>(
renderer,
@ -163,7 +172,7 @@ public class Controller implements Initializable {
tryParse(cameraXTextField.getText()),
tryParse(cameraYTextField.getText()),
tryParse(cameraZTextField.getText())
)), true);
)));
cameraXTextField.textProperty().addListener(cameraPosUpdate);
cameraYTextField.textProperty().addListener(cameraPosUpdate);
@ -195,6 +204,15 @@ public class Controller implements Initializable {
bitCrushSlider.valueProperty().addListener(bitCrushListener);
bitCrushCheckBox.selectedProperty().addListener(bitCrushListener);
fileChooser.setInitialFileName("out.wav");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", "*.*"),
new FileChooser.ExtensionFilter("WAV Files", "*.wav"),
new FileChooser.ExtensionFilter("Wavefront OBJ Files", "*.obj"),
new FileChooser.ExtensionFilter("SVG Files", "*.svg"),
new FileChooser.ExtensionFilter("Text Files", "*.txt")
);
chooseFileButton.setOnAction(e -> {
File file = fileChooser.showOpenDialog(stage);
if (file != null) {
@ -202,6 +220,8 @@ public class Controller implements Initializable {
}
});
recordButton.setOnAction(event -> toggleRecord());
setObjectRotateSpeed(DEFAULT_ROTATE_SPEED);
renderer.addEffect(EffectType.SCALE, scaleEffect);
@ -212,14 +232,34 @@ public class Controller implements Initializable {
new Thread(renderer).start();
}
private void toggleRecord() {
recording = !recording;
if (recording) {
recordLabel.setText("Recording...");
recordButton.setText("Stop Recording");
renderer.startRecord();
} else {
recordButton.setText("Record");
AudioInputStream input = renderer.stopRecord();
try {
File file = fileChooser.showSaveDialog(stage);
SimpleDateFormat formatter= new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
Date date = new Date(System.currentTimeMillis());
if (file == null) {
file = new File("out-" + formatter.format(date) + ".wav");
}
AudioSystem.write(input, AudioFileFormat.Type.WAVE, file);
input.close();
recordLabel.setText("Saved to " + file.getAbsolutePath());
} catch (IOException e) {
recordLabel.setText("Error saving file");
e.printStackTrace();
}
}
}
private void setFocalLength(double focalLength) {
Vector3 pos = (Vector3) producer.setFrameSettings(
ObjSettingsFactory.focalLength(focalLength),
true
);
cameraXTextField.setText(String.valueOf(pos.getX()));
cameraYTextField.setText(String.valueOf(pos.getY()));
cameraZTextField.setText(String.valueOf(pos.getZ()));
producer.setFrameSettings(ObjSettingsFactory.focalLength(focalLength));
}
private void setObjectRotateSpeed(double rotateSpeed) {

Wyświetl plik

@ -12,15 +12,13 @@ public class ObjFrameSet implements FrameSet<List<Shape>> {
private final WorldObject object;
private final Camera camera;
private final boolean isDefaultPosition;
private Vector3 rotation = new Vector3();
private Double rotateSpeed = 0.0;
public ObjFrameSet(WorldObject object, Camera camera, boolean isDefaultPosition) {
public ObjFrameSet(WorldObject object, Camera camera) {
this.object = object;
this.camera = camera;
this.isDefaultPosition = isDefaultPosition;
}
@Override
@ -31,13 +29,10 @@ public class ObjFrameSet implements FrameSet<List<Shape>> {
// TODO: Refactor!
@Override
public Object setFrameSettings(Object settings) {
public void setFrameSettings(Object settings) {
if (settings instanceof ObjFrameSettings obj) {
if (obj.focalLength != null && camera.getFocalLength() != obj.focalLength) {
camera.setFocalLength(obj.focalLength);
if (isDefaultPosition) {
camera.findZPos(object);
}
}
if (obj.cameraPos != null && camera.getPos() != obj.cameraPos) {
camera.setPos(obj.cameraPos);
@ -52,7 +47,5 @@ public class ObjFrameSet implements FrameSet<List<Shape>> {
object.resetRotation();
}
}
return camera.getPos();
}
}

Wyświetl plik

@ -50,12 +50,9 @@ public class ObjParser extends FileParser<FrameSet<List<Shape>>> {
@Override
public FrameSet<List<Shape>> parse() throws IllegalArgumentException, IOException {
object = new WorldObject(input);
camera.findZPos(object);
if (isDefaultPosition) {
camera.findZPos(object);
}
return new ObjFrameSet(object, camera, isDefaultPosition);
return new ObjFrameSet(object, camera);
}
// If camera position arguments haven't been specified, automatically work out the position of

Wyświetl plik

@ -40,9 +40,7 @@ public abstract class Shape implements FrameSet<List<Shape>> {
}
@Override
public Object setFrameSettings(Object settings) {
return null;
}
public void setFrameSettings(Object settings) {}
/* SHAPE HELPER FUNCTIONS */

Wyświetl plik

@ -18,7 +18,5 @@ public class ShapeFrameSet implements FrameSet<List<Shape>> {
}
@Override
public Object setFrameSettings(Object settings) {
return null;
}
public void setFrameSettings(Object settings) {}
}

Wyświetl plik

@ -12,16 +12,16 @@
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.RowConstraints?>
<GridPane alignment="center" hgap="10" prefHeight="719.0" prefWidth="400.0" vgap="10" xmlns="http://javafx.com/javafx/16" xmlns:fx="http://javafx.com/fxml/1">
<GridPane alignment="center" hgap="10" prefHeight="790.0" prefWidth="400.0" vgap="10" xmlns="http://javafx.com/javafx/16" xmlns:fx="http://javafx.com/fxml/1">
<columnConstraints>
<ColumnConstraints />
</columnConstraints>
<rowConstraints>
<RowConstraints />
</rowConstraints>
<AnchorPane prefHeight="737.0" prefWidth="400.0">
<Button fx:id="chooseFileButton" layoutX="8.0" layoutY="15.0" mnemonicParsing="false" prefHeight="26.0" prefWidth="119.0" text="Choose File" />
<SplitPane dividerPositions="0.31499312242090777, 0.6158872077028884" layoutX="6.0" layoutY="63.0" orientation="VERTICAL" prefHeight="649.0" prefWidth="388.0">
<AnchorPane prefHeight="810.0" prefWidth="400.0">
<Button fx:id="chooseFileButton" layoutX="8.0" layoutY="24.0" mnemonicParsing="false" prefHeight="26.0" prefWidth="114.0" text="Choose File" />
<SplitPane dividerPositions="0.31499312242090777, 0.6158872077028884" layoutX="6.0" layoutY="118.0" orientation="VERTICAL" prefHeight="666.0" prefWidth="388.0">
<AnchorPane minHeight="0.0" minWidth="0.0" prefHeight="178.0" prefWidth="396.0">
<Slider fx:id="rotateSpeedSlider" blockIncrement="0.05" layoutX="116.0" layoutY="90.0" majorTickUnit="1.0" max="10.0" prefHeight="38.0" prefWidth="247.0" showTickLabels="true" showTickMarks="true" />
<Label layoutX="37.0" layoutY="88.0" text="Rotate speed" />
@ -69,10 +69,12 @@
<TextField fx:id="rotateYTextField" layoutX="218.0" layoutY="139.0" prefHeight="26.0" prefWidth="58.0" text="0" />
<Label layoutX="283.0" layoutY="143.0" text="z :" />
<TextField fx:id="rotateZTextField" layoutX="302.0" layoutY="139.0" prefHeight="26.0" prefWidth="58.0" text="0" />
<Button fx:id="resetRotationButton" layoutX="145.0" layoutY="183.0" mnemonicParsing="false" text="Reset Rotation" />
<Button fx:id="resetRotationButton" layoutX="145.0" layoutY="189.0" mnemonicParsing="false" text="Reset Rotation" />
</AnchorPane>
</TitledPane>
</SplitPane>
<Label fx:id="fileLabel" layoutX="134.0" layoutY="20.0" maxWidth="250.0" />
<Label fx:id="fileLabel" layoutX="140.0" layoutY="29.0" maxWidth="250.0" />
<Button fx:id="recordButton" layoutX="8.0" layoutY="68.0" mnemonicParsing="false" prefHeight="26.0" prefWidth="114.0" text="Record" />
<Label fx:id="recordLabel" layoutX="140.0" layoutY="73.0" maxWidth="250.0" />
</AnchorPane>
</GridPane>