From: Svjatoslav Agejenko Date: Wed, 26 Aug 2026 17:40:32 +0000 (+0300) Subject: feat: make SOUND/BEEP audible, add SPACE$ and LET, guard empty loops X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=b7b8fcc61852d28df06825ca6cbdd533a2a79d29;p=crtbasic.git feat: make SOUND/BEEP audible, add SPACE$ and LET, guard empty loops - 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 --- diff --git a/Documentation/language/index.org b/Documentation/language/index.org index 16d2503..fc85de7 100644 --- a/Documentation/language/index.org +++ b/Documentation/language/index.org @@ -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 | diff --git a/src/main/java/eu/svjatoslav/crtbasic/Main.java b/src/main/java/eu/svjatoslav/crtbasic/Main.java index e711966..a97c836 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/Main.java +++ b/src/main/java/eu/svjatoslav/crtbasic/Main.java @@ -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); diff --git a/src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java b/src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java index 89e5a19..84b367f 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java +++ b/src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java @@ -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. * *

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.

* - *

No actual audio is produced: this class models timing only.

+ *

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.

*/ 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 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; + } } diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java index 0ebd829..86e7f75 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java +++ b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java @@ -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(); diff --git a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java index dd56223..631de7f 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java +++ b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java @@ -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 index 0000000..738eeb0 --- /dev/null +++ b/src/test/java/eu/svjatoslav/crtbasic/audio/SoundQueueTest.java @@ -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"); + } +} diff --git a/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java b/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java index c61a022..11df273 100644 --- a/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java +++ b/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java @@ -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(