feat: make SOUND/BEEP audible, add SPACE$ and LET, guard empty loops
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Wed, 26 Aug 2026 17:40:32 +0000 (20:40 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Wed, 26 Aug 2026 17:40:32 +0000 (20:40 +0300)
- SoundQueue renders a phase-continuous square wave (44.1 kHz 16-bit)
  through a daemon-fed SourceDataLine in GUI mode; falls back to
  timing-only in headless/tests and when no audio device opens
- SPACE$ returns n spaces (previously misread as an array reference,
  surfacing as "Subscript out of range")
- LET keyword parses as plain scalar or array-element assignment
- empty-body DO/WHILE spins now enforce the --steps budget, so
  headless INKEY$ wait loops terminate instead of hanging

Documentation/language/index.org
src/main/java/eu/svjatoslav/crtbasic/Main.java
src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java
src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
src/test/java/eu/svjatoslav/crtbasic/audio/SoundQueueTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java

index 16d2503..fc85de7 100644 (file)
@@ -76,6 +76,8 @@ Important consequences:
   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*.
@@ -202,7 +204,7 @@ END FUNCTION
 | =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 |
@@ -255,6 +257,7 @@ Semantics worth knowing:
 | =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 |
index e711966..a97c836 100644 (file)
@@ -91,7 +91,7 @@ public final class Main {
 
         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);
index 89e5a19..84b367f 100644 (file)
@@ -1,7 +1,15 @@
 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
@@ -15,7 +23,14 @@ package eu.svjatoslav.crtbasic.audio;
  * 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 {
 
@@ -23,15 +38,59 @@ 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.
@@ -47,4 +106,108 @@ public final class SoundQueue {
         }
         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;
+    }
 }
index 0ebd829..86e7f75 100644 (file)
@@ -717,7 +717,8 @@ public final class Interpreter {
                 }
             }
             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()));
             }
@@ -886,6 +887,12 @@ public final class Interpreter {
             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());
                 }
             }
@@ -895,6 +902,11 @@ public final class Interpreter {
                             && 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()) {
@@ -1489,6 +1501,14 @@ public final class Interpreter {
             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();
index dd56223..631de7f 100644 (file)
@@ -95,6 +95,11 @@ public final class Parser {
                 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();
diff --git a/src/test/java/eu/svjatoslav/crtbasic/audio/SoundQueueTest.java b/src/test/java/eu/svjatoslav/crtbasic/audio/SoundQueueTest.java
new file mode 100644 (file)
index 0000000..738eeb0
--- /dev/null
@@ -0,0 +1,81 @@
+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");
+    }
+}
index c61a022..11df273 100644 (file)
@@ -426,6 +426,65 @@ class InterpreterFeaturesTest {
         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(