lawless-legends/Platform/Apple/tools/jace/src/main/java/jace/apple2e/Speaker.java

308 lines
9.3 KiB
Java

/**
* Copyright 2024 Brendan Robert
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
package jace.apple2e;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.concurrent.ExecutionException;
import java.util.logging.Level;
import java.util.logging.Logger;
import jace.Emulator;
import jace.LawlessLegends;
import jace.config.ConfigurableField;
import jace.config.InvokableAction;
import jace.core.Device;
import jace.core.RAMEvent;
import jace.core.RAMListener;
import jace.core.SoundMixer;
import jace.core.SoundMixer.SoundBuffer;
import jace.core.SoundMixer.SoundError;
import jace.core.TimedDevice;
import jace.core.Utility;
import javafx.stage.FileChooser;
/**
* Apple // Speaker Emulation Created on May 9, 2007, 9:55 PM
*
* @author Brendan Robert (BLuRry) brendan.robert@gmail.com
*/
public class Speaker extends Device {
static boolean fileOutputActive = false;
static OutputStream out;
@ConfigurableField(category = "sound", name = "1mhz timing", description = "Force speaker output to 1mhz?")
public static boolean force1mhz = true;
@ConfigurableField(category = "sound", name = "Show sound", description = "Use black color value to show sound output")
public static boolean showSound = false;
@InvokableAction(category = "sound", name = "Record sound", description="Toggles recording (saving) sound output to a file", defaultKeyMapping = "ctrl+shift+w")
public static void toggleFileOutput() {
if (fileOutputActive) {
try {
out.close();
} catch (IOException ex) {
Logger.getLogger(Speaker.class.getName()).log(Level.SEVERE, null, ex);
}
out = null;
fileOutputActive = false;
} else {
FileChooser fileChooser = new FileChooser();
File f = fileChooser.showSaveDialog(LawlessLegends.getApplication().primaryStage);
if (f == null) {
return;
}
try {
out = new FileOutputStream(f);
fileOutputActive = true;
} catch (FileNotFoundException ex) {
Logger.getLogger(Speaker.class.getName()).log(Level.SEVERE, null, ex);
}
}
}
/**
* Counter tracks the number of cycles between sampling
*/
private double counter = 0;
/**
* Level is the number of cycles the speaker has been on
*/
private int level = 0;
/**
* Idle cycles counts the number of cycles the speaker has not been changed
* (used to deactivate sound when not in use)
*/
private int idleCycles = 0;
/**
* Playback volume (should be < 1423)
*/
@ConfigurableField(name = "Speaker Volume", shortName = "vol", description = "Should be under 1400")
public static int VOLUME = 400;
private int currentVolume = 0;
private int fadeOffAmount = 1;
/**
* Manifestation of the apple speaker softswitch
*/
private boolean speakerBit = false;
private static double TICKS_PER_SAMPLE = ((double) TimedDevice.NTSC_1MHZ) / SoundMixer.RATE;
private RAMListener listener = null;
private SoundBuffer buffer = null;
/**
* Number of idle cycles until speaker playback is deactivated
*/
@ConfigurableField(name = "Idle cycles before sleep", shortName = "idle")
// public static int MAX_IDLE_CYCLES = (int) (SoundMixer.BUFFER_SIZE * TICKS_PER_SAMPLE * 2);
public static int MAX_IDLE_CYCLES = (int) TimedDevice.NTSC_1MHZ / 4;
/**
* Suspend playback of sound
*
* @return
*/
@Override
public boolean suspend() {
boolean result = super.suspend();
speakerBit = false;
if (buffer != null) {
try {
buffer.shutdown();
} catch (InterruptedException | ExecutionException | SoundError e) {
// Ignore
} finally {
buffer = null;
}
}
Emulator.withComputer(c->c.getMotherboard().cancelSpeedRequest(this));
return result;
}
/**
* Start or resume playback of sound
*/
@Override
public void resume() {
if (Utility.isHeadlessMode()) {
return;
}
if (buffer == null || !buffer.isAlive()) {
try {
buffer = SoundMixer.createBuffer(false);
} catch (InterruptedException | ExecutionException | SoundError e) {
e.printStackTrace();
detach();
return;
}
}
if (buffer != null) {
counter = 0;
idleCycles = 0;
level = 0;
} else {
Logger.getLogger(getClass().getName()).severe("Unable to get audio buffer for speaker!");
detach();
return;
}
if (force1mhz) {
TICKS_PER_SAMPLE = ((double) TimedDevice.NTSC_1MHZ) / SoundMixer.RATE;
} else {
TICKS_PER_SAMPLE = Emulator.withComputer(c-> ((double) c.getMotherboard().getSpeedInHz()) / SoundMixer.RATE, 0.0);
}
super.resume();
}
/**
* Reset idle counter whenever sound playback occurs
*/
public void resetIdle() {
currentVolume = VOLUME;
idleCycles = 0;
if (!isRunning()) {
resume();
}
}
/**
* Motherboard cycle tick Every 23 ticks a sample will be added to the
* buffer If the buffer is full, this will block until there is room in the
* buffer, thus keeping the emulation in sync with the sound
*/
@Override
public void tick() {
if (speakerBit) {
level++;
if (showSound) {
VideoNTSC.CHANGE_BLACK_COLOR(40, 20, 20);
}
} else if (showSound) {
VideoNTSC.CHANGE_BLACK_COLOR(20,20,40);
}
if (idleCycles++ >= MAX_IDLE_CYCLES && (currentVolume <= 0 || !speakerBit)) {
suspend();
if (showSound) {
VideoNTSC.CHANGE_BLACK_COLOR(0,0,0);
}
}
counter += 1.0d;
if (counter >= TICKS_PER_SAMPLE) {
if (idleCycles >= MAX_IDLE_CYCLES) {
currentVolume -= fadeOffAmount;
}
playSample(level * currentVolume);
// Emulator.withComputer(c->c.getMotherboard().requestSpeed(this));
// Set level back to 0
level = 0;
// Set counter to 0
counter -= TICKS_PER_SAMPLE;
}
}
private void toggleSpeaker(RAMEvent e) {
// if (e.getType() == RAMEvent.TYPE.WRITE) {
// level += 2;
// }
speakerBit = !speakerBit;
resetIdle();
}
private void playSample(int sample) {
try {
if (buffer == null || !buffer.isAlive()) {
// Logger.getLogger(getClass().getName()).severe("Audio buffer not initalized properly!");
buffer = SoundMixer.createBuffer(false);
if (buffer == null) {
System.err.println("Unable to create emergency audio buffer, detaching speaker");
detach();
return;
}
}
buffer.playSample((short) sample);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} catch (SoundError e) {
System.err.println("Sound error, detaching speaker: " + e.getMessage());
e.printStackTrace();
detach();
buffer = null;
}
if (fileOutputActive) {
byte[] bytes = new byte[2];
bytes[0] = (byte) (sample & 0x0ff);
bytes[1] = (byte) ((sample >> 8) & 0x0ff);
try {
out.write(bytes, 0, 2);
} catch (IOException ex) {
Logger.getLogger(getClass().getName()).log(Level.SEVERE, "Error recording sound", ex);
toggleFileOutput();
}
}
}
/**
* Add a memory event listener for C03x for capturing speaker events
*/
private void configureListener() {
listener = Emulator.withMemory(m->m.observe("Speaker", RAMEvent.TYPE.ANY, 0x0c030, 0x0c03f, this::toggleSpeaker), null);
}
private void removeListener() {
Emulator.withMemory(m->m.removeListener(listener));
}
/**
* Returns "Speaker"
*
* @return "Speaker"
*/
@Override
protected String getDeviceName() {
return "Speaker";
}
@Override
public String getShortName() {
return "spk";
}
@Override
public final void reconfigure() {
}
@Override
public void attach() {
configureListener();
}
@Override
public void detach() {
removeListener();
super.detach();
}
}