assigned.
- =DIM name AS INTEGER= fixes a suffix-less name's type the same way
a suffix would.
+- =LET= is accepted as an explicit-assignment keyword (=LET x = 1= is
+ plain assignment, including =LET a(i) = v=).
- =CONST name = expr [, …]= defines module-level named constants
(may reference earlier constants); reassigning one raises
*Duplicate definition*.
| =CLOSE [#n]= | Bare form closes all |
| =ON ERROR GOTO label= / =ON ERROR GOTO 0= | Enable / disable trapping |
| =RESUME=, =RESUME NEXT=, =RESUME label= | Retry, continue, or jump |
-| =SOUND freq, duration= | Timing modeled (18.2 ticks/s), no audio yet |
+| =SOUND freq, duration= | Audible square wave (PC-speaker timbre) in GUI mode, one-note queue at 18.2 ticks/s; timing-only when headless or no audio device |
| =BEEP= | Desugars to =SOUND 800, 4.55= (the classic ~1/4 s beep) |
| =SWAP a, b= | Exchanges two variables or array elements; string/number mixes raise *Type mismatch* |
| =OUT port, value= | VGA DAC ports only; others ignored |
| =LEN=, =LEFT$=, =RIGHT$=, =MID$= | string length and slices (1-based) |
| =UCASE$=, =LCASE$= | case conversion |
| =LTRIM$=, =RTRIM$= | strip spaces |
+| =SPACE$(n)= | string of n spaces |
| =INKEY$= | one key, non-blocking; =""= when idle |
| =INPUT$(n)= | read up to n input bytes (extended keys arrive as =CHR$(0)= + scancode) |
| =EOF(n)= | -1 at end of file channel |
final VgaDevice vga = new VgaDevice();
final KeyboardQueue keys = new KeyboardQueue();
- final Interpreter interpreter = new Interpreter(vga, keys, new SoundQueue());
+ final Interpreter interpreter = new Interpreter(vga, keys, new SoundQueue(!headless));
interpreter.setMaxStatements(steps);
interpreter.setSourceLines(source.split("\n", -1));
interpreter.setBlockOnInput(!headless);
package eu.svjatoslav.crtbasic.audio;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import javax.sound.sampled.AudioFormat;
+import javax.sound.sampled.AudioSystem;
+import javax.sound.sampled.DataLine;
+import javax.sound.sampled.SourceDataLine;
+
/**
- * SOUND timing model.
+ * SOUND timing model with real PC-speaker-style audio.
*
* <p>SOUND note plays in the background while the program
* continues — but the note queue is only ONE note deep: the next SOUND
* for 0.4 ticks, about 22 ms). Frequency does not affect timing; 0 Hz is
* an inaudible rest that takes its full duration like any other note.</p>
*
- * <p>No actual audio is produced: this class models timing only.</p>
+ * <p>Audio: when constructed with {@code new SoundQueue(true)}, notes are
+ * rendered as a phase-continuous square wave (the PC speaker timbre) into
+ * a {@link SourceDataLine} by a daemon thread. "Finished" for the
+ * one-deep queue means the note's samples are fully handed to the line —
+ * program pacing stays at real-time speed, lagging audible playback by at
+ * most the line buffer (~0.25 s, a constant offset). When audio is
+ * disabled or the device cannot be opened, the class degrades to modeling
+ * timing only, exactly like before.</p>
*/
public final class SoundQueue {
private static final double TICKS_PER_SECOND = 18.2;
private static final double NANOS_PER_TICK = 1_000_000_000.0 / TICKS_PER_SECOND;
- /** When the currently playing note finishes (System.nanoTime clock). */
+ /** PCM output format: 44.1 kHz, 16-bit, mono, signed little-endian. */
+ static final int SAMPLE_RATE = 44100;
+ /** Square-wave peak amplitude (0.25 of full scale — easy on the ears). */
+ private static final short AMPLITUDE = 8192;
+ /** Line buffer: ~0.25 s — caps how far playback lags program pacing. */
+ private static final int BUFFER_BYTES = SAMPLE_RATE / 4 * 2;
+ /** Sample-generation chunk: 1024 frames ≈ 23 ms. */
+ private static final int CHUNK_FRAMES = 1024;
+
+ /** One note: square wave at frequency Hz (0 = rest) for frames samples. */
+ private record Note(int frequency, long frames, CountDownLatch queued) {
+ }
+
+ private final boolean audioWanted;
+ /** When the currently playing note finishes (timing-only fallback). */
private long noteFinishNanos;
+ // Audio engine state (only touched when audioWanted).
+ private final ArrayBlockingQueue<Note> pending = new ArrayBlockingQueue<>(2);
+ private volatile Note lastNote;
+ private volatile SourceDataLine line;
+ private volatile boolean audioFailed;
+ private volatile boolean audioStarted;
+
+ /** Timing-only queue (tests, headless runs, GameDriver). */
+ public SoundQueue() {
+ this(false);
+ }
+
+ /**
+ * @param audioWanted true to play notes through the default audio
+ * device (GUI mode); falls back to timing-only when
+ * no device can be opened.
+ */
+ public SoundQueue(final boolean audioWanted) {
+ this.audioWanted = audioWanted;
+ }
+
/**
* {@code SOUND frequency, duration}: starts a note. Blocks while the
* previous note is still playing; returns as soon as the new note
- * starts (it then keeps "playing" in the background).
+ * starts (it then keeps playing in the background).
*/
- public synchronized void sound(final int frequency, final double durationTicks) {
+ public void sound(final int frequency, final double durationTicks) {
+ if (audioWanted && !audioFailed && ensureAudio()) {
+ soundAudible(frequency, durationTicks);
+ } else {
+ soundTimingOnly(durationTicks);
+ }
+ }
+
+ /** Original behavior: nanos clock, no samples. */
+ private synchronized void soundTimingOnly(final double durationTicks) {
long now = System.nanoTime();
// Compare, don't subtract: with the initial 0 sentinel a naive
// (finish - now) overflows to a huge positive value.
}
noteFinishNanos = now + Math.round(durationTicks * NANOS_PER_TICK);
}
+
+ private void soundAudible(final int frequency, final double durationTicks) {
+ final Note previous = lastNote;
+ if (previous != null) {
+ try {
+ previous.queued.await();
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ final long frames = Math.round(durationTicks / TICKS_PER_SECOND * SAMPLE_RATE);
+ final Note note = new Note(frequency, frames, new CountDownLatch(1));
+ lastNote = note;
+ try {
+ pending.put(note);
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ note.queued.countDown();
+ }
+ }
+
+ /**
+ * Opens the line and starts the daemon renderer on first use. Returns
+ * false (and latches audioFailed) when no output device is available.
+ */
+ private synchronized boolean ensureAudio() {
+ if (audioStarted) {
+ return !audioFailed;
+ }
+ audioStarted = true;
+ try {
+ final AudioFormat format = new AudioFormat(SAMPLE_RATE, 16, 1, true, false);
+ final DataLine.Info info = new DataLine.Info(SourceDataLine.class, format);
+ final SourceDataLine newLine = (SourceDataLine) AudioSystem.getLine(info);
+ newLine.open(format, BUFFER_BYTES);
+ newLine.start();
+ line = newLine;
+ final Thread thread = new Thread(this::renderLoop, "crtbasic-audio");
+ thread.setDaemon(true);
+ thread.start();
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ final SourceDataLine open = line;
+ if (open != null) {
+ open.close();
+ }
+ }));
+ } catch (final Exception e) {
+ audioFailed = true;
+ System.err.println("Audio unavailable, SOUND will be silent: " + e.getMessage());
+ }
+ return !audioFailed;
+ }
+
+ /**
+ * Renderer: takes notes, writes their samples to the line (blocking
+ * writes throttle generation to playback speed), then releases the
+ * SOUND call that is waiting to queue the next note.
+ */
+ private void renderLoop() {
+ final byte[] chunk = new byte[CHUNK_FRAMES * 2];
+ final double[] phase = {0.0};
+ while (true) {
+ final Note note;
+ try {
+ note = pending.poll(200, TimeUnit.MILLISECONDS);
+ } catch (final InterruptedException e) {
+ return;
+ }
+ if (note == null) {
+ continue;
+ }
+ long remaining = note.frames;
+ while (remaining > 0) {
+ final int frames = (int) Math.min(remaining, CHUNK_FRAMES);
+ renderPcm(chunk, frames, phase, note.frequency);
+ line.write(chunk, 0, frames * 2);
+ remaining -= frames;
+ }
+ note.queued.countDown();
+ }
+ }
+
+ /**
+ * Renders {@code frames} square-wave samples (50% duty cycle) as 16-bit
+ * little-endian PCM. The phase accumulator carries across calls, so
+ * back-to-back notes have no boundary click; frequency 0 renders
+ * silence (a rest of the same length).
+ */
+ static void renderPcm(final byte[] buffer, final int frames, final double[] phase,
+ final int frequency) {
+ final double step = frequency / (double) SAMPLE_RATE;
+ double p = phase[0];
+ for (int i = 0; i < frames; i++) {
+ p += step;
+ if (p >= 1.0) {
+ p -= 1.0;
+ }
+ final short sample = frequency == 0 ? 0 : p < 0.5 ? AMPLITUDE : (short) -AMPLITUDE;
+ buffer[2 * i] = (byte) sample;
+ buffer[2 * i + 1] = (byte) (sample >> 8);
+ }
+ phase[0] = p;
+ }
}
}
}
case Ast.SoundStmt s -> {
- // Timing modeled (1-note queue, 18.2 ticks/s); no actual audio.
+ // 1-note queue at 18.2 ticks/s; audible square wave when
+ // the SoundQueue was created with audio enabled.
sound.sound(toInt(eval(s.frequency()), s.line()),
num(eval(s.duration()), s.line()));
}
case Ast.IfStmt s -> execBlock(truthy(eval(s.condition())) ? s.thenBody() : s.elseBody());
case Ast.WhileStmt s -> {
while (truthy(eval(s.condition()))) {
+ // An empty body never passes through exec(), so the
+ // statement budget must be enforced on the spin itself
+ // (WHILE INKEY$ = "": WEND in headless runs).
+ if (s.body().isEmpty() && ++executed > maxStatements) {
+ throw new StepsLimitException(executed);
+ }
execBlock(s.body());
}
}
&& truthy(eval(s.preCondition())) == s.preIsUntil()) {
break;
}
+ // Same empty-body budget guard as WHILE above
+ // (DO UNTIL INKEY$ <> "": LOOP wait loops).
+ if (s.body().isEmpty() && ++executed > maxStatements) {
+ throw new StepsLimitException(executed);
+ }
execBlock(s.body());
if (s.postCondition() != null
&& truthy(eval(s.postCondition())) == s.postIsUntil()) {
case "RTRIM$" -> {
return str(singleArgValue(c), c.line()).replaceAll(" +$", "");
}
+ case "SPACE$" -> {
+ // SPACE$(n): a string of n spaces; n < 0 is illegal.
+ final int n = toInt(singleArgValue(c), c.line());
+ if (n < 0) {
+ throw error("Illegal function call", c.line());
+ }
+ return " ".repeat(n);
+ }
case "INKEY$" -> {
// Non-blocking: "" when no key is pending.
return inkey();
case "GET" -> getStatement();
case "PUT" -> putStatement();
case "MID$" -> midStatement();
+ case "LET" -> {
+ // LET x = expr — the explicit-assignment keyword.
+ next();
+ yield assignmentOrCallStatement();
+ }
case "DIM" -> dimStatement();
case "FOR" -> forStatement();
case "IF" -> ifStatement();
--- /dev/null
+package eu.svjatoslav.crtbasic.audio;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Device-free SoundQueue tests: PCM rendering and the timing-only path.
+ * The audible path (SourceDataLine) is exercised manually through the GUI
+ * launcher — CI must not depend on audio hardware.
+ */
+class SoundQueueTest {
+
+ private static short sampleAt(final byte[] buffer, final int frame) {
+ return (short) ((buffer[2 * frame] & 0xFF) | buffer[2 * frame + 1] << 8);
+ }
+
+ @Test
+ void zeroFrequencyRendersSilence() {
+ final byte[] buffer = new byte[2000];
+ SoundQueue.renderPcm(buffer, 1000, new double[] {0.0}, 0);
+ for (final byte b : buffer) {
+ assertEquals(0, b);
+ }
+ }
+
+ @Test
+ void squareWaveAlternatesAtTheRequestedFrequency() {
+ // 440 Hz for exactly one second: a 50%-duty square wave crosses
+ // zero 880 times (twice per period).
+ final int frames = SoundQueue.SAMPLE_RATE;
+ final byte[] buffer = new byte[frames * 2];
+ SoundQueue.renderPcm(buffer, frames, new double[] {0.0}, 440);
+ int crossings = 0;
+ for (int i = 1; i < frames; i++) {
+ if (sampleAt(buffer, i - 1) != sampleAt(buffer, i)) {
+ crossings++;
+ }
+ }
+ assertTrue(Math.abs(crossings - 880) <= 2,
+ "expected ~880 crossings, got " + crossings);
+ // Both plateau levels are present and reach full amplitude.
+ short min = 0, max = 0;
+ for (int i = 0; i < frames; i++) {
+ min = (short) Math.min(min, sampleAt(buffer, i));
+ max = (short) Math.max(max, sampleAt(buffer, i));
+ }
+ assertTrue(max > 7000 && min < -7000, "amplitude plateaus missing");
+ }
+
+ @Test
+ void phaseCarriesAcrossChunksWithoutSeam() {
+ final int frames = 5000;
+ final byte[] whole = new byte[frames * 2];
+ SoundQueue.renderPcm(whole, frames, new double[] {0.0}, 440);
+
+ final byte[] chunked = new byte[frames * 2];
+ final double[] phase = {0.0};
+ final byte[] first = new byte[2000 * 2];
+ SoundQueue.renderPcm(first, 2000, phase, 440);
+ System.arraycopy(first, 0, chunked, 0, first.length);
+ final byte[] rest = new byte[(frames - 2000) * 2];
+ SoundQueue.renderPcm(rest, frames - 2000, phase, 440);
+ System.arraycopy(rest, 0, chunked, first.length, rest.length);
+
+ org.junit.jupiter.api.Assertions.assertArrayEquals(whole, chunked);
+ }
+
+ @Test
+ void timingOnlyQueueHonorsNoteDuration() {
+ final SoundQueue queue = new SoundQueue();
+ queue.sound(440, 10); // starts the note, returns immediately
+ final long started = System.nanoTime();
+ queue.sound(440, 1); // blocks until the 10-tick note finishes
+ final long elapsedMillis = (System.nanoTime() - started) / 1_000_000;
+ // 10 ticks at 18.2 ticks/s ≈ 549 ms; allow wide scheduling slack.
+ assertTrue(elapsedMillis >= 400, "finished too early: " + elapsedMillis + " ms");
+ assertTrue(elapsedMillis < 5000, "blocked too long: " + elapsedMillis + " ms");
+ }
+}
assertTrue(e.getMessage().contains("Duplicate definition"));
}
+ @Test
+ void letAssignsLikePlainAssignment() {
+ final Interpreter interpreter = run("""
+ LET a = 40
+ LET a = a + 2
+ DIM arr(1 TO 3)
+ LET arr(2) = 7
+ LET b = arr(2)
+ """);
+ assertEquals(42.0, interpreter.variable("a"));
+ assertEquals(7.0, interpreter.variable("b"));
+ }
+
+ @Test
+ void emptyBodyDoLoopStillHitsTheStatementLimit() {
+ final Interpreter interpreter = new Interpreter(
+ new VgaDevice(), new KeyboardQueue(), new SoundQueue());
+ interpreter.setBlockOnInput(false);
+ interpreter.setMaxStatements(1000);
+ // Headless INKEY$ is always "", so this wait loop can only end via
+ // the statement budget (Sine computation.bas).
+ final Interpreter.StepsLimitException e = assertThrows(
+ Interpreter.StepsLimitException.class,
+ () -> interpreter.run(Parser.parse(
+ "DO UNTIL INKEY$ <> \"\"\nLOOP\n")));
+ assertTrue(e.getMessage().contains("statement limit reached"));
+ }
+
+ @Test
+ void emptyBodyWhileLoopStillHitsTheStatementLimit() {
+ final Interpreter interpreter = new Interpreter(
+ new VgaDevice(), new KeyboardQueue(), new SoundQueue());
+ interpreter.setBlockOnInput(false);
+ interpreter.setMaxStatements(1000);
+ assertThrows(Interpreter.StepsLimitException.class,
+ () -> interpreter.run(Parser.parse(
+ "WHILE INKEY$ = \"\"\nWEND\n")));
+ }
+
+ @Test
+ void spaceReturnsTheRequestedNumberOfSpaces() {
+ final Interpreter interpreter = run("""
+ a$ = SPACE$(5)
+ b$ = "x" + SPACE$(3) + "y"
+ e$ = SPACE$(0)
+ """);
+ assertEquals(" ", interpreter.variable("a$"));
+ assertEquals("x y", interpreter.variable("b$"));
+ assertEquals("", interpreter.variable("e$"));
+ }
+
+ @Test
+ void spaceRejectsNegativeCount() {
+ final Interpreter.InterpreterException e = assertThrows(
+ Interpreter.InterpreterException.class,
+ () -> run("a$ = SPACE$(-1)"));
+ assertTrue(e.getMessage().contains("Illegal function call"));
+ }
+
@Test
void constRejectsRedefinition() {
final Interpreter.InterpreterException e = assertThrows(