/* CassetteSoundVoice.java (c) 2009-2015 Edward Swartz All rights reserved. This program and the accompanying materials are made available under the terms of the Eclipse Public License v1.0 which accompanies this distribution, and is available at http://www.eclipse.org/legal/epl-v10.html */ package v9t9.audio.sound; import java.util.Arrays; import java.util.Queue; import java.util.concurrent.LinkedBlockingQueue; import javax.sound.sampled.AudioFormat; import ejs.base.settings.ISettingSection; import ejs.base.sound.IFlushableSoundVoice; /** * Reproduce the sound generated by the cassette recording. * * From <http://nouspikel.com/ti99/tms9901.htm#Ti99> * * <pre> * Pin INT13* / P9 is used to output sound to the cassette, this will be digital sound of course, modulated via an electronic circuit in the console. * </pre> * * And from * <http://nouspikel.group.shef.ac.uk//ti99/cassette.htm#Cassette%20tape * %20format>: * * <pre> * Texas Instruments adopted a frequency modulation encoding system to store data on tape. * This is only a convention, and you may come up with another, if you feel like it. Similarly, TI defined the format the data * should have whithin a tape file. Again, this is only a convention. * </pre> * * (From the above, the modulation is not a convention!) * * <p/> * * From my investigation, the SBO to the cassette out changes the sign of the * modulation waveform recorded to the cassette. Also, the <i>speed</i> at which * the sign changes controls the waveform generated: the full waveform is * emitted between each sign change. * * @author ejs * */ public class CassetteSoundVoice extends ClockedSoundVoice implements IFlushableSoundVoice { private static final short[] cassetteChirp = { 0x400, 0x1570, 0x1814, 0x0d13, 0x456, 0x305, 0x0d6e, 0x200d, 0x2c8a, 0x2a1c, 0x1d92, 0x0e90, 0x0787, 0x07d2, 0x0db7, 0x1093, 0x1044, 0x0d61, 0x0c6e, 0x0e05, 0x10c3, 0x15e0, 0x1a0c, 0x1e28, 0x21a0, 0x2978, 0x3417, 0x4060, 0x445b, 0x31f2, 0x07f9 }; final static int cassetteChirpMag = 0x4800; private boolean wasSet; private boolean state; private boolean origState; private int[] deltas = new int[0]; private int deltaIdx = 0; private int baseCycles; private boolean motor; private float prevV; private float sign = 1f; private int leftover; private float dcOffset; private Queue<Float> samples = new LinkedBlockingQueue<Float>(); private AudioFormat sampleFormat; public CassetteSoundVoice(String name) { super(name); } @Override public void setupVoice() { setVolume((byte) (state ? MAX_VOLUME : 0)); wasSet = true; } /* (non-Javadoc) * @see org.ejs.emul.core.sound.ISoundVoice#setSoundClock(int) */ public void setSoundClock(int soundClock) { this.soundClock = soundClock; } /* (non-Javadoc) * @see org.ejs.emul.core.sound.ISoundVoice#reset() */ public void reset() { wasSet = false; origState = false; leftover = 0; deltaIdx = 0; baseCycles = 0; prevV = 0f; sign = 1f; dcOffset = 0f; } /* (non-Javadoc) * @see v9t9.engine.sound.SoundVoice#isActive() */ @Override public boolean isActive() { return super.isActive() || !samples.isEmpty(); } public synchronized void setState(int curr) { boolean newState = curr >= 0; curr = absp1(curr); // always note a change; the speed is what counts if (motor) { int offs = curr >= baseCycles ? curr - baseCycles : curr; state = newState; baseCycles = curr; appendPos(state ? offs : -offs-1); } } /** * @param pos * @throws AssertionError */ protected void appendPos(int pos) throws AssertionError { if (deltaIdx > 0 && deltas[deltaIdx - 1] == pos) return; if (deltaIdx >= deltas.length) { int newlen = deltas.length * 2; if (newlen < 16) newlen = 16; deltas = Arrays.copyOf(deltas, newlen); } deltas[deltaIdx++] = pos; } /** * Set the rate of raw audio data coming into the cassette * @param rate */ public void setSampleRate(float rate) { if (rate <= 0) this.sampleFormat = null; else this.sampleFormat = new AudioFormat(rate, 16, 1, true, false); } /** * Add a sample of raw audio data coming into the cassette * @param f */ public void addSample(float f) { samples.add(f); } public boolean generate(float[] soundGeneratorWorkBuffer, int from, int to) { // playing back audio read from the cassette if (!samples.isEmpty()) { Object[] samps = samples.toArray(); samples.clear(); if (sampleFormat != null && soundClock > 0) { float outIdx = 0; float scale = sampleFormat.getFrameRate() / soundClock; while (from < to && outIdx < samps.length) { float sample = (Float) samps[(int) outIdx]; soundGeneratorWorkBuffer[from++] += sample; outIdx += scale; } } } return wasSet; } /* (non-Javadoc) * @see v9t9.base.sound.ITimeAdjustSoundVoice#flushAudio(float[], int, int) */ @Override public synchronized boolean flushAudio(float[] soundGeneratorWorkBuffer, int from, int to, int totalCycles) { // constructing the (output) audio for the cassette boolean generated = false; if (from >= to || deltaIdx <= 0) { dcOffset /= 1.1; } else { if (Double.isNaN(dcOffset)) dcOffset = 0; generated = true; int ratio = 128 + balance; float sampleL = ((256 - ratio) * 1f) / 128.f; float sampleR = (ratio * 1f) / 128.f; int totalSamps = to - from; int total = leftover; for (int i = 0; i < deltaIdx; i++) total += absp1(deltas[i]); if (total == 0) total = 1; int firstFrom = from; int idx = 0; int consumed = absp1(deltas[idx]) + leftover; int next = from + (int) ((long) consumed * totalSamps / total); idx++; int origFrom = from; //leftover = 0; //System.out.println("**" + (next-origFrom)); boolean on = origState; sign = on ? 1f : -1f; int diff = next - origFrom; while (from < to) { float v; // avoid weird spikes if (diff > 0) { int fullPos = (from - origFrom) * cassetteChirp.length; int aPos = fullPos / diff; v = cassetteChirp[aPos] / (float) cassetteChirpMag; } else { v = prevV; } prevV = v; v *= sign; // this seems to be how the "perfect" wave is messed up in analog-land //dcOffset += v * 8 / diff; //v += dcOffset; soundGeneratorWorkBuffer[from++] += sampleL * v; soundGeneratorWorkBuffer[from++] += sampleR * v; if (from >= next) { if (idx < deltaIdx) { boolean nextOn = (deltas[idx] >= 0); consumed += absp1(deltas[idx++]); next = firstFrom + (int) ((long) consumed * totalSamps / total); on = nextOn; sign = -sign; dcOffset *= 0.9; // avoid too much cumulative distortion origState = on; } else { on = state; if (deltaIdx > 0) origState = !on; // actually changed else origState = state; // nope, still handling this one break; } origFrom = from; diff = next - origFrom; } } } deltaIdx = 0; // origState = state; leftover = totalCycles - baseCycles; if (leftover < 0) leftover = 0; baseCycles = totalCycles; return generated; } /** * @param i * @return */ private int absp1(int i) { return i < 0 ? -(i+1) : i; } @Override public void loadState(ISettingSection settings) { if (settings == null) return; super.loadState(settings); setVolume((byte) (settings.getBoolean("State") ? MAX_VOLUME : 0)); motor = settings.getBoolean("Motor"); } @Override public void saveState(ISettingSection settings) { super.saveState(settings); settings.put("State", Boolean.toString(getVolume() != 0)); settings.put("Motor", Boolean.toString(motor)); } /** * @param b */ public void setMotor(int curr, boolean b) { motor = b; baseCycles = curr; } }