From e8e7774fc4db8d2684a78045ee84569f3c4d8e3b Mon Sep 17 00:00:00 2001 From: Chad Date: Mon, 26 Oct 2026 19:23:06 -0500 Subject: [PATCH] Add bounded compact PSG streams, attenuation and looping silent-pause effects --- README.md | 12 ++ docs/COMPACT-AUDIO-VALIDATION.md | 112 +++++++++++ docs/COMPACT-AUDIO.md | 125 ++++++++++++ examples/stream/COSTS.json | 112 +++++++++++ examples/stream/DEMO-BUILD.json | 5 + examples/stream/DEMO-NATIVE.json | 10 + examples/stream/NATIVE.json | 17 ++ examples/stream/chirp.h | 12 ++ examples/stream/chirp.json | 29 +++ examples/stream/phrase.h | 13 ++ examples/stream/phrase.json | 329 +++++++++++++++++++++++++++++++ examples/stream/phrase_plain.h | 29 +++ examples/stream_demo.c | 92 +++++++++ include/lt_audio.h | 16 +- include/lt_sfx.h | 12 ++ include/lt_stream.h | 60 ++++++ src/lt_audio.c | 120 ++++++++--- src/lt_sfx.c | 45 ++++- src/lt_stream.c | 262 ++++++++++++++++++++++++ tests/stream_bench.c | 82 ++++++++ tests/stream_core.c | 329 +++++++++++++++++++++++++++++++ tools/build.py | 5 + tools/compile_stream.py | 101 ++++++++++ tools/measure_audio_omf.py | 45 +++++ 24 files changed, 1947 insertions(+), 27 deletions(-) create mode 100644 docs/COMPACT-AUDIO-VALIDATION.md create mode 100644 docs/COMPACT-AUDIO.md create mode 100644 examples/stream/COSTS.json create mode 100644 examples/stream/DEMO-BUILD.json create mode 100644 examples/stream/DEMO-NATIVE.json create mode 100644 examples/stream/NATIVE.json create mode 100644 examples/stream/chirp.h create mode 100644 examples/stream/chirp.json create mode 100644 examples/stream/phrase.h create mode 100644 examples/stream/phrase.json create mode 100644 examples/stream/phrase_plain.h create mode 100644 examples/stream_demo.c create mode 100644 include/lt_stream.h create mode 100644 src/lt_stream.c create mode 100644 tests/stream_bench.c create mode 100644 tests/stream_core.c create mode 100644 tools/compile_stream.py create mode 100644 tools/measure_audio_omf.py diff --git a/README.md b/README.md index 24ae5bb..fac03a4 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,18 @@ synthetic pixels/state and do not qualify DOS ABI, IRQ timing or hardware. The original public-edition build record is historical. `VALIDATION.md` also records the results for this local native-audio integration candidate. +## Compact streams, ducking, loops and pause + +The optional `lt_stream.h` module plays bounded compiled logical PSG streams +with phrase references and explicit rate/clock/chip metadata. Music and effects +have independent additive attenuation, and clips can loop finitely or forever, +cancel at a boundary, stop immediately, or silently pause/resume. Stream and +clip pause freeze their cursors while preserving ownership and original levels. +`STRMDEMO.EXE` is an original standalone DOS example; `/auto` demonstrates +music ducking, a looping effect, both pause controls and clean cancellation. +See [the API and format guide](docs/COMPACT-AUDIO.md) and +[measured validation](docs/COMPACT-AUDIO-VALIDATION.md). + ## Native PSG effects and scores Short effects are versioned `lt_sfx_clip` command sequences. A game initializes diff --git a/docs/COMPACT-AUDIO-VALIDATION.md b/docs/COMPACT-AUDIO-VALIDATION.md new file mode 100644 index 0000000..ac56884 --- /dev/null +++ b/docs/COMPACT-AUDIO-VALIDATION.md @@ -0,0 +1,112 @@ +# Compact audio validation + +Measured against main `06a1e4b`, using Open Watcom 1.9 with the repository's +`-0 -ms -ecc -os -s -w4 -wx` DOS build, and the managed GCC UCRT64 wrapper +with `-std=c89 -pedantic-errors -Wall -Wextra -Werror -O2` on the host. + +## Resource costs + +OMF segment lengths, measured by `tools/measure_audio_omf.py`: + +| Module | Main code bytes | New code bytes | Added code | +| --- | ---: | ---: | ---: | +| `lt_audio` | 4505 | 5135 | 630 | +| `lt_sfx` | 1778 | 2014 | 236 | +| `lt_stream` (optional) | 0 | 2083 | 2083 | + +All three have zero static data/constant segment bytes. Added module code +totals 2949 bytes when all three are linked; stream code is omitted by simply +not compiling it. The attenuation/pause changes cost 630 code bytes in the +existing audio module; they are not behind a preprocessor flag. These are +actual module segment lengths, excluding shared C runtime/linker padding. +They are not `.obj` container sizes or a universal application EXE delta. + +| Caller-owned object | Main DOS bytes | New DOS bytes | +| --- | ---: | ---: | +| `lta_handle` | 58 | 66 | +| `lt_sfx_player` | 8 | 12 | +| `lt_sfx_clip` | 18 | 18 | +| `lt_stream` descriptor | absent | 20 | +| `lt_stream_player` | absent | 20 | +| Reference cursor (included in player) | absent | 10 | + +The host ABI sizes are 72, 24, 32 and 32 bytes for the handle, clip player, +stream descriptor and stream player respectively. DOS state sizes were +executed on the emulated 8088; baseline sizes were emitted by the same Watcom +compiler using main's headers. No heap, whole-song buffer or module globals +were added. Per-call stack costs are not separately measured; the DOS test +and demonstration use the repository's 4096-byte stack setting. + +## Original asset sizes + +| Asset | Literal stream | Referenced stream | With 20-byte DOS descriptor | +| --- | ---: | ---: | ---: | +| Two-note chirp (six ticks) | 15 | 15 | 35 | +| Four-note phrase repeated eight times (480 ticks) | 225 | 35 | 245 literal / 55 referenced | + +The chirp also fits the existing clip representation: 12 event bytes plus +its 18-byte DOS descriptor = 30 bytes. The stream is five bytes larger for +this short cue; compression is not universally beneficial. The repeated +phrase saves 190 data bytes (84.4% of the stream alone, 77.6% including its +descriptor). The two players each have their own separate state cost above. +Generated C headers, source JSON, plain comparison header and measured OMF +records are in `examples/stream`. + +## Native playback cost + +A private Tandy 1000 HX 8088 emulator configured at 4,772,728 Hz executed the +DOS tests and `STRMBEN.EXE`. The shared emulator lease was acquired normally; +only copied private disks and the run's own child process were used. The +benchmark latches the existing BIOS PIT counter without changing its mode, +rate or interrupt handler. It measures tick calls with a counting sink, +including ownership/shadow work; disk output and start/validation are outside +the timed intervals. Real PSG port transport and acoustics are not represented +by this sink. BIOS interrupts remain enabled and may affect samples. + +| Case | Calls | Total PIT clocks | Mean microseconds/call after probe subtraction | +| --- | ---: | ---: | ---: | +| Short chirp, stream effect | 70 | 159956 | 1870.1 | +| Same chirp, existing clip effect | 70 | 142046 | 1655.6 | +| Literal phrase, music | 4810 | 3439130 | 554.2 | +| Referenced phrase, music | 4810 | 3466592 | 559.0 | + +The empty measurement probe averaged 53.738 PIT clocks across 1000 calls. +Conversion uses 1,193,182 PIT clocks/second. Each asset was played ten times. +The referenced phrase adds about 4.8 microseconds per tick relative to the +literal stream in this run. The short effect stream costs about 214.4 extra +microseconds per tick relative to the existing clip. These are averages over +the named mixes of event, wait and ending ticks, not worst-case IRQ deadlines +or hardware timing guarantees. The hard playback bound is 64 commands/tick. +Foreground validation is deliberately limited to 8192 structural inspections +and 8192 expanded commands; an earlier 65535-step prototype made the deliberate +malformed-bound test unnecessarily slow on the 8088. + +## Checks completed + +- All repository host tests pass, preserving existing audio, SFX, score, + approved Doebel and SMS boot traces. +- All repository modules, DOS examples and DOS tests compile with the actual + 8086/8088 Watcom configuration and warnings as errors. +- Native `STREAM`, `AUDIO`, `SFX` and `SCORE` pass. New checks cover generated + compressed/literal byte-trace equivalence, immediate attenuation and + saturation, original level preservation, looping effects over progressing + music, finite/infinite loop policy, priority replacement, boundary cancel, + immediate stop, paused stop/cancel, frozen waits/reference state, repeated + silent pause/resume, independent audible layers, global mute/unmute while + paused, and tone2/noise restoration including complete latch writes. +- Malformed tests cover truncation, alignment, offsets and 16-bit overflow + boundaries, nested references, zero counts/waits, undeclared channels, + excessive expanded work, the exact 64-command accepted boundary and runtime + budget failure. Invalid starts validate before borrowing/output. +- The standalone `STRMDEMO /auto` runs 360 retraces and reports status zero, + all channels muted/released. A final demo build compares IRQ0 at program + entry and exit directly; see the final native demo record. The full-suite + harness's original vector sample was taken before BIOS initialization and + is not a valid entry/exit comparison. +- Independent review identified an uncapped structural reference scan; it was + capped before these final checks. A subsequent review found no additional + concrete reference/arithmetic/playback defect. + +Native logs are in `examples/stream/NATIVE.json`; the final demo entry/exit +check is in `examples/stream/DEMO-NATIVE.json`. This is emulated DOS validation, +not a physical Tandy audibility claim. No publishing operation was performed. diff --git a/docs/COMPACT-AUDIO.md b/docs/COMPACT-AUDIO.md new file mode 100644 index 0000000..cc91ccd --- /dev/null +++ b/docs/COMPACT-AUDIO.md @@ -0,0 +1,125 @@ +# Compact native PSG audio + +The optional `lt_stream.c` player adds immutable compiled logical register +streams with backward phrase references. It borrows the existing audio +ownership/shadow/overlay path; it does not parse PSGlib files or add another +synthesizer. Existing scores, clip files and approved songs are unchanged. +The ideas are informed by [PSGlib's transport API](https://github.com/sverx/devkitSMS/blob/master/PSGlib/src/PSGlib.h) +and [phrase-reference format](https://github.com/sverx/PSGlib/blob/master/documents/PSG%20file%20format.txt). +Implementation is original C89, with no assembly copied from PSGlib. + +## Attenuation + +`lta_music_attenuation(audio, channel, steps)` independently adds 0..15 PSG +attenuation steps on each music channel. `lta_sfx_attenuation(audio, steps)` +does the same for the effect layer. Both saturate at 15 (silence), immediately +update the audible layer, and preserve original levels for later restoration. +Music changes beneath a borrowed channel only update its shadow. Effects +restore the latest music period, mode and adjusted level when stopped. +Init/acquire resets controls to zero; old code emits the same default traces. +The handle/player layouts grow: rebuild callers and modules together. +`LTA_API_VERSION` is now `0x0202`; clip descriptors and clip wire formats retain +their existing versions. +These are PSG-only controls: PC speaker callbacks retain fixed physical +amplitude. They do not implement speaker gain or boost. + +## Looping clips and silent pause + +`lt_sfx_loop(player, clip, total_plays)` accepts 1 for once, 2..65535 for a +finite total number of plays, and 0 for infinite. The ordinary trigger remains +one-shot. The next pass starts on the boundary tick, with no extra blank tick; +it first silences/reset its reserved effect voices, then applies tick zero. +Music keeps progressing below repeated effects. Priority replacement still +uses newest-wins for equal priority and rejects lower priority. + +`lt_sfx_cancel` finishes the current pass. `lt_sfx_stop` immediately restores +music and discards the cursor. `lt_sfx_pause(player, 1)` silences the effect +and freezes event position, elapsed time and loop counter. It holds the +reservation, including the expanded tone2/noise pair, until resume or stop. +Music updates beneath it remain hidden. Resume (`paused=0`) restores its exact +current adjusted volumes; periods/noise state have remained reserved. A +boundary cancel while paused waits for resume; immediate stop also works +while paused. Resume after stop returns `LTA_STATE` and never restarts it. + +`lt_stream_pause` applies the same silent freeze to a stream cursor, reference +counter and wait. Music pause silences the music layer on that handle, while +an overlay continues. Effect pause leaves unborrowed music audible. Neither +changes channel ownership. Disjoint handles are unaffected. At most one base +transport and one overlay transport may drive a handle. Global mute and +attenuation changes respect both pause flags. `lta_layer_pause` provides the +same volume policy for callers with another transport; those callers must +freeze their own event clocks. For static scores, pair this layer control with withholding score ticks; +this addition does not change the score speaker callback's existing policy. + +## Stream descriptor and commands + +Compile `lt_chip.c`, `lt_audio.c`, `lt_stream.c` and their headers. Streams +may update music under a clip overlay without requiring `lt_sfx.c` themselves. +The caller owns the player, descriptor and near-addressed bytes and keeps the +descriptor/data immutable through stop/end. Version 1 requires explicit byte +length (1..32767), chip `LTST_TANDY`, nonzero PSG clock Hz and rational tick +rate, a channel mask and priority. Metadata does not schedule, resample or +retune output: service at that cadence using the application's own clock. +Periods are already compiled for that clock. Sega's noise generator differs +from Tandy's; format support does not reproduce SMS percussion exactly. + +All multi-byte operands below are little endian. No command is a raw PSG byte. + +| Opcode | Operands | Meaning | +| --- | --- | --- | +| `00` | none | End; required as the last source byte | +| `01` | ticks:u16 | Wait 1..65535 caller ticks | +| `02` | offset:u16, bytes:u16, count:u8 | Play an earlier complete literal block 1..255 more times | +| `10..12` | period:u16, level:u8 | Complete tone operation, channel 0..2 | +| `13` | mode:u8, level:u8 | Complete noise operation | +| `20..23` | level:u8 | Level of an initialized voice, channel 0..3 | +| `30` | mask:u8 | Stop declared channels | + +References must point backwards to earlier command boundaries, stay entirely +before the reference, and contain no references or END. Reference depth is +one by design. The block can contain waits. It is interpreted incrementally, +with one small return frame and no full-song decompression. There are no +callback/event hooks. Noise rate 3 requires tone 2 initialized first in the +stream and both channels declared. Tone2-dependent stop and restore retain +the existing paired-channel policy. Each tone operation emits a complete +latch/data pair, so other channel writes cannot leave a stale latch behind. + +`lt_stream_start` validates in foreground before output. It checks record +widths, offsets/boundaries, masks, operand ranges, initialized voices, coupled +noise, mandatory END and nonzero total duration. Structural inspections and +expanded commands each have an 8192-work limit. Expanded duration is capped +at 2^31-1 ticks; arithmetic checks precede 16-bit additions. Over-limit assets +are rejected with `LTA_INVALID`; they are never partly borrowed/decompressed. +Validation uses bounded stack state, no song-sized scratch buffer or heap. + +At most 64 commands (including REF, WAIT and END) are interpreted per caller +tick. A validated source exceeding this burst limit is rejected at start; +the tick path also enforces the cap and stops/restores its layer on failure. +There is no unbounded catch-up after a foreground stall. Start/validation is +foreground-only. Tick can be called in a caller-owned service context only +when the selected sink and every shared audio access obey its serialization +requirements; the module installs no ISR, clock or arbitrary callbacks. + +A stream effect can replace a stream in its own player according to priority. +An overlay owned by another player returns `LTA_BUSY`: explicitly stop that +clip/player before switching transports. Infinite and cancellable effects use +the clip loop API; stream references represent finite compiled repetition. + +## Building and running + +`python tools/build.py --target dos --watcom C:/WATCOM` builds `STRMDEMO.EXE`, +`STRMBEN.EXE` and the portable checks including `STREAM.EXE`, using 8086/8088 +small-model C89. Copy `STRMDEMO.EXE` to a DOS Tandy and run it directly; no +external asset, Python, installation or conversion is needed. `STRMDEMO /auto` +plays a finite six-second sequence: music, ducking and a looping effect, +effect pause/resume, music pause/resume, clean boundary cancellation and +restored volume. Interactive keys are printed at startup. The demo polls +retrace in foreground and cleans up on Escape, expiry or failure. + +Generated original examples live under `examples/stream`. The optional +developer command `python tools/compile_stream.py input.json output.h` +creates a ready-to-compile header; `--plain` disables references for trace +comparison. It never writes unchecked PSG port streams. Runtime validation +remains authoritative; the compiler only encodes/compresses logical records. + +See `COMPACT-AUDIO-VALIDATION.md` for measured sizes, native timings and checks. diff --git a/examples/stream/COSTS.json b/examples/stream/COSTS.json new file mode 100644 index 0000000..351b5e1 --- /dev/null +++ b/examples/stream/COSTS.json @@ -0,0 +1,112 @@ +{ + "build/baseline/lt_audio.obj": [ + { + "name": "_TEXT", + "segment_class": "CODE", + "bytes": 4505 + }, + { + "name": "CONST", + "segment_class": "DATA", + "bytes": 0 + }, + { + "name": "CONST2", + "segment_class": "DATA", + "bytes": 0 + }, + { + "name": "_DATA", + "segment_class": "DATA", + "bytes": 0 + } + ], + "build/baseline/lt_sfx.obj": [ + { + "name": "_TEXT", + "segment_class": "CODE", + "bytes": 1778 + }, + { + "name": "CONST", + "segment_class": "DATA", + "bytes": 0 + }, + { + "name": "CONST2", + "segment_class": "DATA", + "bytes": 0 + }, + { + "name": "_DATA", + "segment_class": "DATA", + "bytes": 0 + } + ], + "build/dos/lt_audio.obj": [ + { + "name": "_TEXT", + "segment_class": "CODE", + "bytes": 5135 + }, + { + "name": "CONST", + "segment_class": "DATA", + "bytes": 0 + }, + { + "name": "CONST2", + "segment_class": "DATA", + "bytes": 0 + }, + { + "name": "_DATA", + "segment_class": "DATA", + "bytes": 0 + } + ], + "build/dos/lt_sfx.obj": [ + { + "name": "_TEXT", + "segment_class": "CODE", + "bytes": 2014 + }, + { + "name": "CONST", + "segment_class": "DATA", + "bytes": 0 + }, + { + "name": "CONST2", + "segment_class": "DATA", + "bytes": 0 + }, + { + "name": "_DATA", + "segment_class": "DATA", + "bytes": 0 + } + ], + "build/dos/lt_stream.obj": [ + { + "name": "_TEXT", + "segment_class": "CODE", + "bytes": 2083 + }, + { + "name": "CONST", + "segment_class": "DATA", + "bytes": 0 + }, + { + "name": "CONST2", + "segment_class": "DATA", + "bytes": 0 + }, + { + "name": "_DATA", + "segment_class": "DATA", + "bytes": 0 + } + ] +} diff --git a/examples/stream/DEMO-BUILD.json b/examples/stream/DEMO-BUILD.json new file mode 100644 index 0000000..702435d --- /dev/null +++ b/examples/stream/DEMO-BUILD.json @@ -0,0 +1,5 @@ +{ + "bytes": 20796, + "sha256": "66451bdcf7c08f50bd695ace37f99beabb1e1198515cee0ad436ad24773b8782", + "dos_run": "STRMDEMO /auto" +} diff --git a/examples/stream/DEMO-NATIVE.json b/examples/stream/DEMO-NATIVE.json new file mode 100644 index 0000000..f1e0e3c --- /dev/null +++ b/examples/stream/DEMO-NATIVE.json @@ -0,0 +1,10 @@ +{ + "complete": true, + "cpu_hz": 4772728, + "started": "2026-10-27T00:20:04.106793+00:00", + "lease_acquired": true, + "owned_pid": 33936, + "STRMDEMO": "Original PSG stream / duck / looping effect demo\r\nL loop, C cancel at boundary, S stop effect, E pause effect,\r\nP pause music, Escape quit. /auto runs six seconds.\r\nSTREAMDEMO status=0 frames=360 muted/released=1 irq0_unchanged=1\r\n", + "owned_child_stopped": true, + "lease_released": true +} diff --git a/examples/stream/NATIVE.json b/examples/stream/NATIVE.json new file mode 100644 index 0000000..9894af7 --- /dev/null +++ b/examples/stream/NATIVE.json @@ -0,0 +1,17 @@ +{ + "complete": true, + "cpu_hz": 4772728, + "started": "2026-10-27T00:17:45.814649+00:00", + "lease_acquired": true, + "owned_pid": 24936, + "irq0_unchanged": false, + "STREAM": "PASS packed/plain trace\r\nPASS generated assets\r\nPASS attenuation/loops\r\nPASS malformed/bounds\r\nstream checks failures=0; handle=66 clip-player=12 stream=20 player=20 cursor=10\r\n", + "AUDIO": "audio core tests passed\r\n", + "SFX": "sfx core tests passed\r\n", + "SCORE": "PASS shared score scheduler and exact PSG trace\r\n", + "STRMBEN": "probe PIT clocks=53738 calls=1000\r\nchirp PIT clocks=159956 calls=70 bytes=15\r\nclip PIT clocks=142046 calls=70 event-bytes=12\r\nplain-phrase PIT clocks=3439130 calls=4810 bytes=225\r\nphrase PIT clocks=3466592 calls=4810 bytes=35\r\nsizes handle=66 clip-player=12 descriptor=20 stream-player=20 cursor=10\r\n", + "STRMDEMO": "Original PSG stream / duck / looping effect demo\r\nL loop, C cancel at boundary, S stop effect, E pause effect,\r\nP pause music, Escape quit. /auto runs six seconds.\r\nSTREAMDEMO status=0 frames=360 muted/released=1\r\n", + "owned_child_stopped": true, + "lease_released": true, + "irq0_sample_note": "The full-suite harness sampled before BIOS initialization; irq0_unchanged=false is not a program entry/exit comparison. DEMO-NATIVE.json performs the final comparison inside the demo and passes." +} diff --git a/examples/stream/chirp.h b/examples/stream/chirp.h new file mode 100644 index 0000000..2b7016d --- /dev/null +++ b/examples/stream/chirp.h @@ -0,0 +1,12 @@ +/* SPDX-License-Identifier: MIT */ +/* Generated from chirp.json; no runtime conversion. */ +#include "lt_stream.h" +static const lt_u8 chirp_data[] = { + 0x11,0xB4,0x00,0x03,0x01,0x03,0x00,0x11,0x8C,0x00,0x05,0x01, + 0x03,0x00,0x00, +}; +static const lt_stream chirp = { + chirp_data, sizeof(chirp_data), + LTST_VERSION, LTST_TANDY, 2, 0, + 3579545UL, 60UL, 1UL +}; diff --git a/examples/stream/chirp.json b/examples/stream/chirp.json new file mode 100644 index 0000000..829b0fb --- /dev/null +++ b/examples/stream/chirp.json @@ -0,0 +1,29 @@ +{ + "name": "chirp", + "channel_mask": 2, + "clock_hz": 3579545, + "rate_num": 60, + "rate_den": 1, + "commands": [ + [ + "tone", + 1, + 180, + 3 + ], + [ + "wait", + 3 + ], + [ + "tone", + 1, + 140, + 5 + ], + [ + "wait", + 3 + ] + ] +} diff --git a/examples/stream/phrase.h b/examples/stream/phrase.h new file mode 100644 index 0000000..20a4935 --- /dev/null +++ b/examples/stream/phrase.h @@ -0,0 +1,13 @@ +/* SPDX-License-Identifier: MIT */ +/* Generated from phrase.json; no runtime conversion. */ +#include "lt_stream.h" +static const lt_u8 phrase_data[] = { + 0x10,0xAC,0x01,0x05,0x01,0x0F,0x00,0x10,0x53,0x01,0x05,0x01, + 0x0F,0x00,0x10,0x1D,0x01,0x06,0x01,0x0F,0x00,0x10,0x53,0x01, + 0x07,0x01,0x0F,0x00,0x02,0x00,0x00,0x1C,0x00,0x07,0x00, +}; +static const lt_stream phrase = { + phrase_data, sizeof(phrase_data), + LTST_VERSION, LTST_TANDY, 1, 0, + 3579545UL, 60UL, 1UL +}; diff --git a/examples/stream/phrase.json b/examples/stream/phrase.json new file mode 100644 index 0000000..7d0dae4 --- /dev/null +++ b/examples/stream/phrase.json @@ -0,0 +1,329 @@ +{ + "name": "phrase", + "channel_mask": 1, + "clock_hz": 3579545, + "rate_num": 60, + "rate_den": 1, + "commands": [ + [ + "tone", + 0, + 428, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 285, + 6 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 7 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 428, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 285, + 6 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 7 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 428, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 285, + 6 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 7 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 428, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 285, + 6 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 7 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 428, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 285, + 6 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 7 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 428, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 285, + 6 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 7 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 428, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 285, + 6 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 7 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 428, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 5 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 285, + 6 + ], + [ + "wait", + 15 + ], + [ + "tone", + 0, + 339, + 7 + ], + [ + "wait", + 15 + ] + ] +} diff --git a/examples/stream/phrase_plain.h b/examples/stream/phrase_plain.h new file mode 100644 index 0000000..d5505e5 --- /dev/null +++ b/examples/stream/phrase_plain.h @@ -0,0 +1,29 @@ +/* SPDX-License-Identifier: MIT */ +/* Generated from phrase.json; no runtime conversion. */ +#include "lt_stream.h" +static const lt_u8 phrase_data[] = { + 0x10,0xAC,0x01,0x05,0x01,0x0F,0x00,0x10,0x53,0x01,0x05,0x01, + 0x0F,0x00,0x10,0x1D,0x01,0x06,0x01,0x0F,0x00,0x10,0x53,0x01, + 0x07,0x01,0x0F,0x00,0x10,0xAC,0x01,0x05,0x01,0x0F,0x00,0x10, + 0x53,0x01,0x05,0x01,0x0F,0x00,0x10,0x1D,0x01,0x06,0x01,0x0F, + 0x00,0x10,0x53,0x01,0x07,0x01,0x0F,0x00,0x10,0xAC,0x01,0x05, + 0x01,0x0F,0x00,0x10,0x53,0x01,0x05,0x01,0x0F,0x00,0x10,0x1D, + 0x01,0x06,0x01,0x0F,0x00,0x10,0x53,0x01,0x07,0x01,0x0F,0x00, + 0x10,0xAC,0x01,0x05,0x01,0x0F,0x00,0x10,0x53,0x01,0x05,0x01, + 0x0F,0x00,0x10,0x1D,0x01,0x06,0x01,0x0F,0x00,0x10,0x53,0x01, + 0x07,0x01,0x0F,0x00,0x10,0xAC,0x01,0x05,0x01,0x0F,0x00,0x10, + 0x53,0x01,0x05,0x01,0x0F,0x00,0x10,0x1D,0x01,0x06,0x01,0x0F, + 0x00,0x10,0x53,0x01,0x07,0x01,0x0F,0x00,0x10,0xAC,0x01,0x05, + 0x01,0x0F,0x00,0x10,0x53,0x01,0x05,0x01,0x0F,0x00,0x10,0x1D, + 0x01,0x06,0x01,0x0F,0x00,0x10,0x53,0x01,0x07,0x01,0x0F,0x00, + 0x10,0xAC,0x01,0x05,0x01,0x0F,0x00,0x10,0x53,0x01,0x05,0x01, + 0x0F,0x00,0x10,0x1D,0x01,0x06,0x01,0x0F,0x00,0x10,0x53,0x01, + 0x07,0x01,0x0F,0x00,0x10,0xAC,0x01,0x05,0x01,0x0F,0x00,0x10, + 0x53,0x01,0x05,0x01,0x0F,0x00,0x10,0x1D,0x01,0x06,0x01,0x0F, + 0x00,0x10,0x53,0x01,0x07,0x01,0x0F,0x00,0x00, +}; +static const lt_stream phrase = { + phrase_data, sizeof(phrase_data), + LTST_VERSION, LTST_TANDY, 1, 0, + 3579545UL, 60UL, 1UL +}; diff --git a/examples/stream_demo.c b/examples/stream_demo.c new file mode 100644 index 0000000..20a7212 --- /dev/null +++ b/examples/stream_demo.c @@ -0,0 +1,92 @@ +/* SPDX-License-Identifier: MIT */ +/* Original Tandy PSG ducking/loop/pause example. No install or data files. */ +#include +#include +#include +#include +#include "lt_stream.h" +#include "lt_sfx.h" +#include "stream/phrase.h" + +extern void lt_dos_emit(void *, lt_u8); +static lt_u16 port = 0xC0; +static lt_chip chip; +static lta_handle audio; +static lt_stream_player music; +static lt_sfx_player effects; +static const lt_sfx_event engine_events[] = { + {0,LTSFX_OP_TONE,0,210,0,3}, + {6,LTSFX_OP_TONE,0,220,0,5}, + {12,LTSFX_OP_TONE,0,200,0,4} +}; +static const lt_sfx_clip engine = {2,1,2,3,18,0,18,60UL,1UL,engine_events}; + +static int retrace(void) +{ + unsigned i; + for(i=0;i<65535U;++i) if (!(inp(0x3DA)&8)) break; + if(i==65535U) return 0; + for(i=0;i<65535U;++i) if (inp(0x3DA)&8) return 1; + return 0; +} +static lta_status duck(lt_u8 value) +{ + lta_status s = lta_music_attenuation(&audio,0,value); + if(s==LTA_OK) s=lta_music_attenuation(&audio,1,value); + return s; +} +int main(int argc, char **argv) +{ + unsigned frame; + unsigned long irq0 = *(unsigned long __far *)MK_FP(0,0x20); + int key=0, automatic=(argc>1 && !strcmp(argv[1],"/auto")); + lta_request r; + lta_status status; + puts("Original PSG stream / duck / looping effect demo"); + puts("L loop, C cancel at boundary, S stop effect, E pause effect,"); + puts("P pause music, Escape quit. /auto runs six seconds."); + lt_init(&chip,lt_dos_emit,&port); + status=lta_init(&audio,&chip,1); + lta_request_init(&r);r.op=LTA_OP_ACQUIRE;r.mask=15; + if(status==LTA_OK) status=lta_command(&audio,&r); + if(status!=LTA_OK) return 1; + (void)lt_stream_init(&music,&audio); + (void)lt_sfx_init(&effects,&audio); + status=lt_stream_start(&music,&phrase,0); + /* Quiet second voice continues while the effect borrows the melody. */ + r.op=LTA_OP_TONE_START;r.channel=1;r.period=856;r.level=10; + if(status==LTA_OK) status=lta_command(&audio,&r); + for(frame=0;status==LTA_OK && frame<360U;++frame) { + if(!retrace()) { status=LTA_TIME; break; } + key=kbhit()?getch():0; + if(key==27) break; + if(automatic) { + if(frame==60U) key='l'; + if(frame==120U || frame==150U) key='e'; + if(frame==180U || frame==210U) key='p'; + if(frame==240U) key='c'; + } + if(key=='l' || key=='L') { + status=duck(5); + if(status==LTA_OK) status=lt_sfx_loop(&effects,&engine,0); + } + if(key=='c' || key=='C') status=lt_sfx_cancel(&effects); + if(key=='s' || key=='S') status=lt_sfx_stop(&effects); + if((key=='e' || key=='E') && effects.active) + status=lt_sfx_pause(&effects,(lt_u8)!effects.paused); + if(key=='p' || key=='P') + status=lt_stream_pause(&music,(lt_u8)!music.paused); + if(status==LTA_OK) status=lt_stream_tick(&music); + if(status==LTA_OK) status=lt_sfx_tick(&effects); + if(status==LTA_OK && !effects.active && audio.music_attenuation[0]) + status=duck(0); + if(!automatic && frame==359U) frame=0; /* interactive until Escape */ + } + (void)lt_sfx_stop(&effects); + (void)lt_stream_stop(&music); + r.op=LTA_OP_RELEASE;(void)lta_command(&audio,&r); + printf("STREAMDEMO status=%u frames=%u muted/released=%u irq0_unchanged=%u\n", + (unsigned)status,frame,(unsigned)(!audio.active && !chip.owner[0]), + (unsigned)(irq0 == *(unsigned long __far *)MK_FP(0,0x20))); + return status==LTA_OK ? 0 : 1; +} diff --git a/include/lt_audio.h b/include/lt_audio.h index ddf15f2..aff6443 100644 --- a/include/lt_audio.h +++ b/include/lt_audio.h @@ -7,7 +7,7 @@ #include #include "lt_sound.h" -#define LTA_API_VERSION 0x0201U +#define LTA_API_VERSION 0x0202U #if ULONG_MAX == 0xFFFFFFFFUL typedef unsigned long lta_u32; @@ -74,6 +74,8 @@ typedef struct { /* Private overlay state used by the caller-driven clip player. */ lt_u8 effect_mask, effect_active, effect_playing, effect_kind[4]; lt_u8 effect_level[4], effect_noise_mode; + lt_u8 music_attenuation[4], sfx_attenuation; + lt_u8 music_paused, effect_paused; } lta_handle; #define LTA_KIND_NONE 0U @@ -95,6 +97,18 @@ lt_u16 lta_capabilities(void); * must not overlap. Capabilities are static; no hardware/frequency detection. */ lta_status lta_command(lta_handle *handle, const lta_request *request); +/* Add 0..15 PSG attenuation steps, saturating at silence. Original levels + * remain intact. Applies immediately to audible voices, including overlays. + * Init/acquire resets both controls to zero. Serialized like commands. These + * PSG controls do not change the PC speaker's fixed output amplitude. */ +lta_status lta_music_attenuation(lta_handle *handle, lt_u8 channel, + lt_u8 attenuation); +lta_status lta_sfx_attenuation(lta_handle *handle, lt_u8 attenuation); +/* Silence one layer, preserving logical levels and ownership. Transport + * players freeze their own cursors separately. Effect pause retains claims; + * music underneath keeps progressing. Resume restores adjusted volumes. */ +lta_status lta_layer_pause(lta_handle *handle, lt_u8 effect, lt_u8 paused); + /* Internal interface for the reusable clip player in lt_sfx.h. Applications * should use lt_sfx_* instead of calling these overlay helpers directly. */ lta_status lta_overlay_begin(lta_handle *handle, lt_u8 mask, diff --git a/include/lt_sfx.h b/include/lt_sfx.h index a1b9e6a..a4dd3e7 100644 --- a/include/lt_sfx.h +++ b/include/lt_sfx.h @@ -58,6 +58,8 @@ typedef struct { lta_handle *audio; const lt_sfx_clip *clip; lt_u8 event_index, elapsed, active, borrowed_mask; + lt_u16 plays_left; + lt_u8 cancel, paused; } lt_sfx_player; /* Init is silent and may run before audio ownership is acquired. */ @@ -73,6 +75,16 @@ lta_status lt_sfx_trigger(lt_sfx_player *player, const lt_sfx_clip *clip); * applied in order. Expiry restores the latest base music voice state. */ lta_status lt_sfx_tick(lt_sfx_player *player); +/* Total plays: 1 = once, 0 = infinite, 2..65535 = finite. Cancel finishes + * the current pass, then restores music. Pause freezes the cursor and silences + * its layer while holding the reservation; resume restores adjusted levels. + * Cancel while paused takes effect at the boundary after resume. + * Stop is immediate and discards the cursor; resume does not revive a stop. */ +lta_status lt_sfx_loop(lt_sfx_player *player, const lt_sfx_clip *clip, + lt_u16 total_plays); +lta_status lt_sfx_cancel(lt_sfx_player *player); +lta_status lt_sfx_pause(lt_sfx_player *player, lt_u8 paused); + /* Stop restores the latest base music state immediately. Reset also clears * the elapsed tick and event cursor; both are safe when already idle. */ lta_status lt_sfx_stop(lt_sfx_player *player); diff --git a/include/lt_stream.h b/include/lt_stream.h new file mode 100644 index 0000000..d5fcb00 --- /dev/null +++ b/include/lt_stream.h @@ -0,0 +1,60 @@ +/* SPDX-License-Identifier: MIT */ +#ifndef LT_STREAM_H +#define LT_STREAM_H +#include "lt_audio.h" + +#define LTST_VERSION 1U +#define LTST_TANDY 1U +#define LTST_BUDGET 64U +#define LTST_MAX_BYTES 32767U +#define LTST_MAX_STEPS 8192UL + +/* Immutable caller-owned descriptor and near data, no allocation. Version 1: + * 00 END; 01 ticks:u16le WAIT (nonzero); 02 offset:u16le bytes:u16le + * count:u8 REF (backward, whole commands, no nested REF/END); + * 10..12 period:u16le level:u8 TONE channel 0..2; + * 13 mode:u8 level:u8 NOISE; 20..23 level:u8 LEVEL; 30 mask:u8 STOP. + * REF plays the earlier block count additional times. END is compulsory. + * Metadata describes already compiled Tandy periods, not an SMS converter. + * Each tick emits at most 64 logical operations. No raw PSG writes. */ +typedef struct { + const lt_u8 *data; + lt_u16 bytes; + lt_u8 version, chip, channel_mask, priority; + lta_u32 clock_hz, rate_num, rate_den; +} lt_stream; + +typedef struct { + lt_u16 pc, return_pc, ref_start, ref_end; + lt_u8 repeats; +} lt_stream_cursor; + +typedef struct { + lta_handle *audio; + const lt_stream *stream; + lt_stream_cursor cursor; + lt_u16 wait; + lt_u8 active, effect, paused, borrowed_mask; +} lt_stream_player; + +/* Foreground validation expands no song buffer. At most 8192 virtual + * commands, 32767 source bytes, one reference frame and 2^31-1 total ticks. + * Rejects truncation, unaligned references, nesting, oversized bursts, + * invalid channels and tone2-dependent noise without an initialized clock. + * Init/start/validate belong in foreground, never in an IRQ. */ +lta_status lt_stream_validate(const lt_stream *stream); +lta_status lt_stream_init(lt_stream_player *player, lta_handle *audio); +/* effect=1 borrows channels through the existing overlay and priority path; + * effect=0 updates base music, including under an effect. One base player + * and one effect player/clip per handle; caller serializes all operations. + * An effect stream may replace an equal/lower priority stream in this player. + * A different overlay player is busy: stop it explicitly before switching. */ +lta_status lt_stream_start(lt_stream_player *player, const lt_stream *stream, + lt_u8 effect); +lta_status lt_stream_tick(lt_stream_player *player); +/* Pause silences its layer, holds reservations and freezes time. Stop + * discards the cursor, restoring current music for effects or stopping + * declared music channels. */ +lta_status lt_stream_pause(lt_stream_player *player, lt_u8 paused); +lta_status lt_stream_stop(lt_stream_player *player); +#endif diff --git a/src/lt_audio.c b/src/lt_audio.c index f0ee443..e577422 100644 --- a/src/lt_audio.c +++ b/src/lt_audio.c @@ -5,6 +5,14 @@ #define LTA_TONE_BITS 7U #define LTA_U32_HALF 0x80000000UL +static lt_u8 output_level(const lta_handle *h, unsigned channel, + lt_u8 level, int effect) +{ + unsigned sum = (unsigned)level + (effect ? h->sfx_attenuation : + h->music_attenuation[channel]); + return (lt_u8)((h->muted || (effect ? h->effect_paused : h->music_paused) || sum > 15U) ? 15U : sum); +} + static int lta_mask_valid(lt_u8 mask) { return mask != 0U && (mask & 240U) == 0U; @@ -25,7 +33,7 @@ static int lta_owns_mask(const lta_handle *h, lt_u8 mask) static lta_status lta_preflight_active(lta_handle *h) { if (!h || !h->chip || !h->token || h->active > 1U || - h->muted > 1U || (h->mask & 240U) != 0U || + h->muted > 1U || h->music_paused > 1U || h->effect_paused > 1U || (h->mask & 240U) != 0U || (h->playing & (lt_u8)~h->mask) != 0U || (h->deadline_mask & (lt_u8)~h->mask) != 0U || h->effect_active > 1U || (h->effect_mask & 240U) != 0U || @@ -141,9 +149,12 @@ static lta_status lta_acquire(lta_handle *h, const lta_request *r) next.effect_active = 0U; next.effect_playing = 0U; next.effect_noise_mode = 0U; + next.sfx_attenuation = 0U; + next.music_paused = next.effect_paused = 0U; for (i = 0; i < 4U; ++i) { next.kind[i] = LTA_KIND_NONE; next.level[i] = 15U; + next.music_attenuation[i] = 0U; next.effect_kind[i] = LTA_KIND_NONE; next.effect_level[i] = 15U; next.deadline[i] = 0UL; @@ -161,6 +172,7 @@ static lta_status lta_release(lta_handle *h) /* lt_stop preflights its complete (possibly coupled) expansion. */ if (!lt_stop(h->chip, h->token, mask)) return LTA_OWNER; h->active = 0U; + h->music_paused = 0U; h->mask = 0U; h->playing = 0U; h->muted = 0U; @@ -171,6 +183,7 @@ static lta_status lta_release(lta_handle *h) h->effect_active = 0U; h->effect_playing = 0U; h->effect_noise_mode = 0U; + h->effect_paused = 0U; for (i = 0; i < 4U; ++i) { h->kind[i] = LTA_KIND_NONE; h->level[i] = 15U; @@ -190,7 +203,7 @@ static lta_status lta_tone(lta_handle *h, const lta_request *r, int start) if (!(h->mask & bit)) return LTA_OWNER; if (!start && (!(h->playing & bit) || h->kind[r->channel] != LTA_KIND_TONE)) return LTA_STATE; - attenuation = h->muted ? 15U : r->level; + attenuation = output_level(h, r->channel, r->level, 0); /* Store music updates in h; the effect owns hardware until it ends. */ if (!(h->effect_active && (h->effect_mask & bit))) { if (!lt_tone(h->chip, h->token, r->channel, r->period, start)) @@ -213,7 +226,7 @@ static lta_status lta_noise(lta_handle *h, const lta_request *r, int start) if (!(h->mask & 8U)) return LTA_OWNER; if (!start && (!(h->playing & 8U) || h->kind[3] != LTA_KIND_NOISE)) return LTA_STATE; - attenuation = h->muted ? 15U : r->level; + attenuation = output_level(h, 3U, r->level, 0); if ((r->mode & 3U) == 3U) { if (!(h->mask & 4U)) return LTA_STATE; if (h->effect_active && (h->effect_mask & 4U)) { @@ -246,7 +259,7 @@ static lta_status lta_level(lta_handle *h, const lta_request *r) if (r->channel >= 4U || r->level > 15U) return LTA_INVALID; bit = (lt_u8)(1U << r->channel); if (!(h->mask & bit)) return LTA_OWNER; - attenuation = h->muted ? 15U : r->level; + attenuation = output_level(h, r->channel, r->level, 0); if (!(h->effect_active && (h->effect_mask & bit))) { if (!lt_attenuate(h->chip, h->token, r->channel, attenuation, 1)) return LTA_OWNER; @@ -276,29 +289,32 @@ static lta_status lta_noise_mode(lta_handle *h, const lta_request *r) return LTA_OK; } -static lta_status lta_mute(lta_handle *h, lt_u8 enabled) +static lta_status refresh_volumes(lta_handle *h) { unsigned i; - if (enabled > 1U) return LTA_INVALID; - if (h->muted == enabled) return LTA_OK; for (i = 0; i < 4U; ++i) { - lt_u8 bit = (lt_u8)(1U << i); - if (h->mask & bit) { - lt_u8 attenuation = 15U; - if (!enabled && h->effect_active && - (h->effect_mask & bit) && (h->effect_playing & bit)) - attenuation = h->effect_level[i]; - else if (!enabled && !(h->effect_active && - (h->effect_mask & bit)) && (h->playing & bit)) - attenuation = h->level[i]; - if (!lt_attenuate(h->chip, h->token, (lt_u8)i, - attenuation, 1)) return LTA_OWNER; + lt_u8 bit = (lt_u8)(1U << i), attenuation = 15U; + if (!(h->mask & bit)) continue; + if (h->effect_mask & bit) { + if (h->effect_playing & bit) + attenuation = output_level(h, i, h->effect_level[i], 1); + } else if (h->playing & bit) { + attenuation = output_level(h, i, h->level[i], 0); } + if (!lt_attenuate(h->chip, h->token, (lt_u8)i, attenuation, 1)) + return LTA_OWNER; } - h->muted = enabled; return LTA_OK; } +static lta_status lta_mute(lta_handle *h, lt_u8 enabled) +{ + if (enabled > 1U) return LTA_INVALID; + if (h->muted == enabled) return LTA_OK; + h->muted = enabled; + return refresh_volumes(h); +} + /* Reserve channels for one short foreground effect. Tone 3 and noise share * generator state, so borrowing either one also reserves the pair when the * music handle owns both voices. */ @@ -326,6 +342,7 @@ lta_status lta_overlay_begin(lta_handle *h, lt_u8 mask, h->effect_active = 1U; h->effect_playing = 0U; h->effect_noise_mode = 0U; + h->effect_paused = 0U; for (i = 0; i < 4U; ++i) { h->effect_kind[i] = LTA_KIND_NONE; h->effect_level[i] = 15U; @@ -353,7 +370,7 @@ lta_status lta_overlay_tone(lta_handle *h, lt_u8 channel, if (!lt_tone(h->chip, h->token, channel, period, start)) return LTA_OWNER; if (!lt_attenuate(h->chip, h->token, channel, - h->muted ? 15U : level, + output_level(h, channel, level, 1), start || h->effect_level[channel] != level)) return LTA_OWNER; h->effect_kind[channel] = LTA_KIND_TONE; @@ -380,7 +397,7 @@ lta_status lta_overlay_noise(lta_handle *h, lt_u8 mode, lt_u8 level) h->effect_kind[3] != LTA_KIND_NOISE); if (!lt_noise(h->chip, h->token, mode, start)) return LTA_OWNER; if (!lt_attenuate(h->chip, h->token, 3U, - h->muted ? 15U : level, + output_level(h, 3U, level, 1), start || h->effect_level[3] != level)) return LTA_OWNER; h->effect_kind[3] = LTA_KIND_NOISE; @@ -439,6 +456,7 @@ lta_status lta_overlay_end(lta_handle *h) h->effect_mask = 0U; h->effect_playing = 0U; h->effect_noise_mode = 0U; + h->effect_paused = 0U; for (i = 0; i < 4U; ++i) { h->effect_kind[i] = LTA_KIND_NONE; h->effect_level[i] = 15U; @@ -451,7 +469,7 @@ lta_status lta_overlay_end(lta_handle *h) if ((h->playing & bit) && h->kind[i] == LTA_KIND_TONE) { (void)lt_tone(h->chip, h->token, (lt_u8)i, h->period[i], 1); (void)lt_attenuate(h->chip, h->token, (lt_u8)i, - h->muted ? 15U : h->level[i], 1); + output_level(h, i, h->level[i], 0), 1); } else { h->chip->tone_valid &= (lt_u8)~bit; } @@ -460,7 +478,7 @@ lta_status lta_overlay_end(lta_handle *h) if ((h->playing & 8U) && h->kind[3] == LTA_KIND_NOISE) { (void)lt_noise(h->chip, h->token, h->noise_mode, 1); (void)lt_attenuate(h->chip, h->token, 3U, - h->muted ? 15U : h->level[3], 1); + output_level(h, 3U, h->level[3], 0), 1); } else { h->chip->noise_valid = 0U; } @@ -524,12 +542,15 @@ lta_status lta_init(lta_handle *handle, lt_chip *chip, lt_u8 token) next.effect_active = 0U; next.effect_playing = 0U; next.effect_noise_mode = 0U; + next.sfx_attenuation = 0U; + next.music_paused = next.effect_paused = 0U; next.last_now = 0UL; { unsigned i; for (i = 0; i < 4U; ++i) { next.kind[i] = LTA_KIND_NONE; next.level[i] = 15U; + next.music_attenuation[i] = 0U; next.effect_kind[i] = LTA_KIND_NONE; next.effect_level[i] = 15U; next.deadline[i] = 0UL; @@ -590,3 +611,56 @@ lta_status lta_command(lta_handle *h, const lta_request *r) default: return LTA_UNSUPPORTED; } } + +lta_status lta_music_attenuation(lta_handle *h, lt_u8 channel, lt_u8 value) +{ + lta_status status; + lt_u8 bit; + if (!h || channel > 3U || value > 15U) return LTA_INVALID; + status = lta_preflight_active(h); + if (status != LTA_OK) return status; + bit = (lt_u8)(1U << channel); + if (!(h->mask & bit)) return LTA_OWNER; + h->music_attenuation[channel] = value; + if ((h->playing & bit) && !(h->effect_mask & bit)) + (void)lt_attenuate(h->chip, h->token, channel, + output_level(h, channel, h->level[channel], 0), 1); + return LTA_OK; +} + +lta_status lta_sfx_attenuation(lta_handle *h, lt_u8 value) +{ + lta_status status; + unsigned i; + if (!h || value > 15U) return LTA_INVALID; + status = lta_preflight_active(h); + if (status != LTA_OK) return status; + h->sfx_attenuation = value; + for (i = 0; i < 4U; ++i) + if (h->effect_playing & (1U << i)) + (void)lt_attenuate(h->chip, h->token, (lt_u8)i, + output_level(h, i, h->effect_level[i], 1), 1); + return LTA_OK; +} + +lta_status lta_layer_pause(lta_handle *h, lt_u8 effect, lt_u8 paused) +{ + lta_status status; + unsigned i; + if (!h || effect > 1U || paused > 1U) return LTA_INVALID; + status = lta_preflight_active(h); + if (status != LTA_OK) return status; + if (effect && !h->effect_active) return LTA_STATE; + if (effect) h->effect_paused = paused; + else h->music_paused = paused; + for (i = 0; i < 4U; ++i) { + lt_u8 bit = (lt_u8)(1U << i); + if (effect) { + if (!(h->effect_playing & bit)) continue; + } else if (!(h->playing & bit) || (h->effect_mask & bit)) continue; + (void)lt_attenuate(h->chip, h->token, (lt_u8)i, + output_level(h, i, effect ? h->effect_level[i] : + h->level[i], effect), 1); + } + return LTA_OK; +} diff --git a/src/lt_sfx.c b/src/lt_sfx.c index c4a9272..e9f1a2f 100644 --- a/src/lt_sfx.c +++ b/src/lt_sfx.c @@ -214,6 +214,8 @@ static void clear_player(lt_sfx_player *player) player->elapsed = 0U; player->active = 0U; player->borrowed_mask = 0U; + player->plays_left = 1U; + player->cancel = player->paused = 0U; } lta_status lt_sfx_init(lt_sfx_player *player, lta_handle *audio) @@ -226,6 +228,8 @@ lta_status lt_sfx_init(lt_sfx_player *player, lta_handle *audio) next.elapsed = 0U; next.active = 0U; next.borrowed_mask = 0U; + next.plays_left = 1U; + next.cancel = next.paused = 0U; *player = next; return LTA_OK; } @@ -259,6 +263,8 @@ lta_status lt_sfx_trigger(lt_sfx_player *player, const lt_sfx_clip *clip) player->elapsed = 0U; player->active = 1U; player->borrowed_mask = borrowed; + player->plays_left = 1U; + player->cancel = player->paused = 0U; return LTA_OK; } @@ -288,13 +294,20 @@ lta_status lt_sfx_tick(lt_sfx_player *player) clear_player(player); return LTA_STATE; } + if (player->paused) return LTA_OK; clip = player->clip; if (!clip || player->elapsed > clip->duration_ticks || player->event_index > clip->event_count) return LTA_INVALID; if (player->elapsed == clip->duration_ticks) { - status = lta_overlay_end(player->audio); - if (status == LTA_OK) clear_player(player); - return status; + if (player->cancel || player->plays_left == 1U) { + status = lta_overlay_end(player->audio); + if (status == LTA_OK) clear_player(player); + return status; + } + if (player->plays_left) --player->plays_left; + status = lta_overlay_stop(player->audio, player->borrowed_mask); + if (status != LTA_OK) return status; + player->elapsed = player->event_index = 0U; } /* One caller tick may contain several ordered register changes. */ while (player->event_index < clip->event_count && @@ -329,3 +342,29 @@ lta_status lt_sfx_reset(lt_sfx_player *player) { return lt_sfx_stop(player); } + +lta_status lt_sfx_loop(lt_sfx_player *p, const lt_sfx_clip *clip, lt_u16 plays) +{ + lta_status status = lt_sfx_trigger(p, clip); + if (status == LTA_OK) p->plays_left = plays; + return status; +} + +lta_status lt_sfx_cancel(lt_sfx_player *p) +{ + if (!p || !p->audio) return LTA_INVALID; + if (p->active) p->cancel = 1U; + return LTA_OK; +} + +lta_status lt_sfx_pause(lt_sfx_player *p, lt_u8 paused) +{ + if (!p || !p->audio || paused > 1U) return LTA_INVALID; + if (!p->active) return LTA_STATE; + { + lta_status status = lta_layer_pause(p->audio, 1U, paused); + if (status != LTA_OK) return status; + } + p->paused = paused; + return LTA_OK; +} diff --git a/src/lt_stream.c b/src/lt_stream.c new file mode 100644 index 0000000..cf44444 --- /dev/null +++ b/src/lt_stream.c @@ -0,0 +1,262 @@ +/* SPDX-License-Identifier: MIT */ +#include "lt_stream.h" +#include + +static lt_u16 word(const lt_u8 *p) +{ + return (lt_u16)((lt_u16)p[0] | ((lt_u16)p[1] << 8)); +} + +static unsigned width(lt_u8 op) +{ + if (op == 0U) return 1U; + if (op == 1U || op == 0x13U) return 3U; + if (op == 2U) return 6U; + if (op >= 0x10U && op <= 0x12U) return 4U; + if ((op >= 0x20U && op <= 0x23U) || op == 0x30U) return 2U; + return 0U; +} + +/* All additions follow subtraction-based bounds checks (16-bit unsigned). */ +static int record(const lt_stream *s, lt_u16 pc, unsigned *n) +{ + if (pc >= s->bytes) return 0; + *n = width(s->data[pc]); + return *n != 0U && *n <= (unsigned)(s->bytes - pc); +} + +static void advance_reference(lt_stream_cursor *c) +{ + if (c->repeats && c->pc == c->ref_end) { + --c->repeats; + c->pc = c->repeats ? c->ref_start : c->return_pc; + } +} + +static void enter_reference(lt_stream_cursor *c, const lt_u8 *p) +{ + c->return_pc = c->pc; + c->ref_start = word(p + 1); + c->ref_end = (lt_u16)(c->ref_start + word(p + 3)); + c->repeats = p[5]; + c->pc = c->ref_start; +} + +lta_status lt_stream_validate(const lt_stream *s) +{ + lt_u16 pc = 0U; + unsigned n, burst = 0U; + lta_u32 steps = 0UL, ticks = 0UL, inspections = 0UL; + lt_u8 tone2 = 0U, noise3 = 0U, playing = 0U; + lt_stream_cursor c; + if (!s || !s->data || !s->bytes || s->bytes > LTST_MAX_BYTES || + s->version != LTST_VERSION || s->chip != LTST_TANDY || + !s->clock_hz || !s->rate_num || !s->rate_den || + !s->channel_mask || (s->channel_mask & 240U)) return LTA_INVALID; + /* Structural pass: references must start/end on earlier boundaries and + * contain no control transfers. Depth is exactly one, never recursive. */ + while (pc < s->bytes) { + const lt_u8 *p = s->data + pc; + if (++inspections > LTST_MAX_STEPS || !record(s, pc, &n)) return LTA_INVALID; + if (*p == 0U && pc != s->bytes - 1U) return LTA_INVALID; + if (*p == 2U) { + lt_u16 start = word(p + 1), len = word(p + 3), scan = 0U; + if (!len || !p[5] || start >= pc || len > pc - start) + return LTA_INVALID; + while (scan < start) { + unsigned k; + if (++inspections > LTST_MAX_STEPS || !record(s, scan, &k)) return LTA_INVALID; + scan = (lt_u16)(scan + k); + } + if (scan != start) return LTA_INVALID; + while (scan < start + len) { + unsigned k; + if (++inspections > LTST_MAX_STEPS || !record(s, scan, &k) || s->data[scan] == 0U || + s->data[scan] == 2U || k > (unsigned)(start + len - scan)) + return LTA_INVALID; + scan = (lt_u16)(scan + k); + } + } + pc = (lt_u16)(pc + n); + } + if (s->data[s->bytes - 1U] != 0U) return LTA_INVALID; + memset(&c, 0, sizeof(c)); + /* Dry run validates expanded semantics and worst per-tick work. */ + for (;;) { + const lt_u8 *p; + lt_u8 op, channel, mask; + advance_reference(&c); + if (!record(s, c.pc, &n) || ++steps > LTST_MAX_STEPS || + ++burst > LTST_BUDGET) return LTA_INVALID; + p = s->data + c.pc; + op = *p; + c.pc = (lt_u16)(c.pc + n); + if (op == 0U) return ticks ? LTA_OK : LTA_INVALID; + if (op == 2U) { enter_reference(&c, p); continue; } + if (op == 1U) { + lt_u16 wait = word(p + 1); + if (!wait || ticks > LTA_MAX_DURATION - wait) return LTA_INVALID; + ticks += wait; + burst = 0U; + continue; + } + if (op == 0x30U) { + mask = p[1]; + if (!mask || (mask & (lt_u8)~s->channel_mask)) return LTA_INVALID; + if ((mask & 12U) && noise3) { tone2 = noise3 = 0U; mask |= 12U; } + playing &= (lt_u8)~mask; + if (mask & 4U) tone2 = 0U; + continue; + } + channel = (lt_u8)(op & 3U); + if (!(s->channel_mask & (1U << channel))) return LTA_INVALID; + if (op >= 0x20U) { + if (p[1] > 15U || !(playing & (1U << channel))) return LTA_INVALID; + } else if (op == 0x13U) { + if (p[1] > 7U || p[2] > 15U) return LTA_INVALID; + playing |= 8U; + noise3 = (lt_u8)((p[1] & 3U) == 3U); + if (noise3 && (!tone2 || (s->channel_mask & 12U) != 12U)) + return LTA_INVALID; + } else { + if (word(p + 1) > 1023U || p[3] > 15U) return LTA_INVALID; + playing |= (lt_u8)(1U << channel); + if (channel == 2U) tone2 = 1U; + } + } +} + +lta_status lt_stream_init(lt_stream_player *p, lta_handle *h) +{ + if (!p || !h) return LTA_INVALID; + memset(p, 0, sizeof(*p)); + p->audio = h; + return LTA_OK; +} + +lta_status lt_stream_stop(lt_stream_player *p) +{ + lta_status status; + lta_request r; + if (!p || !p->audio) return LTA_INVALID; + if (!p->active) return LTA_OK; + if (p->effect) status = lta_overlay_end(p->audio); + else { + lta_request_init(&r); + r.op = LTA_OP_STOP; + r.mask = p->stream->channel_mask; + status = lta_command(p->audio, &r); + if (status == LTA_OK) (void)lta_layer_pause(p->audio, 0U, 0U); + } + if (status == LTA_OK || status == LTA_STATE) p->active = 0U; + return status; +} + +lta_status lt_stream_start(lt_stream_player *p, const lt_stream *s, + lt_u8 effect) +{ + lta_status status; + if (!p || !p->audio || effect > 1U) return LTA_INVALID; + status = lt_stream_validate(s); + if (status != LTA_OK) return status; + if (!p->audio->active) return LTA_STATE; + if (s->channel_mask & (lt_u8)~p->audio->mask) return LTA_OWNER; + if (effect && p->active && p->effect && + s->priority < p->stream->priority) return LTA_BUSY; + if (effect && p->audio->effect_active && !(p->active && p->effect)) + return LTA_BUSY; + status = lt_stream_stop(p); + if (status != LTA_OK) return status; + p->borrowed_mask = 0U; + if (effect) { + status = lta_overlay_begin(p->audio, s->channel_mask, &p->borrowed_mask); + if (status != LTA_OK) return status; + } + p->stream = s; + memset(&p->cursor, 0, sizeof(p->cursor)); + p->wait = 0U; + p->paused = 0U; + p->effect = effect; + p->active = 1U; + return LTA_OK; +} + +static lta_status apply(lt_stream_player *p, const lt_u8 *data) +{ + lt_u8 op = data[0], ch = (lt_u8)(op & 3U); + lta_request r; + /* Validate again at the output boundary; metadata/data must remain + * immutable during playback. Ownership is checked by the backend too. */ + if (op == 0x30U) { + if (!data[1] || (data[1] & (lt_u8)~p->stream->channel_mask)) + return LTA_INVALID; + } else if (!(p->stream->channel_mask & (1U << ch))) return LTA_OWNER; + if (p->effect) { + if (op <= 0x12U) return lta_overlay_tone(p->audio, ch, + word(data + 1), data[3]); + if (op == 0x13U) return lta_overlay_noise(p->audio, data[1], data[2]); + if (op == 0x30U) return lta_overlay_stop(p->audio, data[1]); + /* LEVEL needs an existing logical effect voice. Reissue its complete + * operation, never an unchecked latch-dependent byte. */ + if (!(p->audio->effect_playing & (1U << ch))) return LTA_STATE; + if (ch == 3U) return lta_overlay_noise(p->audio, + p->audio->effect_noise_mode, data[1]); + return lta_overlay_tone(p->audio, ch, p->audio->chip->period[ch], data[1]); + } + lta_request_init(&r); + r.channel = ch; + if (op <= 0x12U) { + r.op = LTA_OP_TONE_START; r.period = word(data + 1); r.level = data[3]; + } else if (op == 0x13U) { + r.op = LTA_OP_NOISE_START; r.mode = data[1]; r.level = data[2]; + } else if (op == 0x30U) { + r.op = LTA_OP_STOP; r.mask = data[1]; + } else { r.op = LTA_OP_LEVEL; r.level = data[1]; } + return lta_command(p->audio, &r); +} + +lta_status lt_stream_tick(lt_stream_player *p) +{ + unsigned work, n; + lta_status status = LTA_INVALID; + if (!p || !p->audio) return LTA_INVALID; + if (!p->active || p->paused) return LTA_OK; + if (p->wait && --p->wait) return LTA_OK; + for (work = 0U; work < LTST_BUDGET; ++work) { + const lt_u8 *data; + lt_u8 op; + advance_reference(&p->cursor); + if (!record(p->stream, p->cursor.pc, &n)) break; + data = p->stream->data + p->cursor.pc; + op = *data; + p->cursor.pc = (lt_u16)(p->cursor.pc + n); + if (op == 0U) return lt_stream_stop(p); + if (op == 1U) { + p->wait = word(data + 1); + if (!p->wait) break; + return LTA_OK; + } + if (op == 2U) { + if (p->cursor.repeats) break; + enter_reference(&p->cursor, data); + continue; + } + status = apply(p, data); + if (status != LTA_OK) break; + status = LTA_INVALID; + } + (void)lt_stream_stop(p); + return status; +} + +lta_status lt_stream_pause(lt_stream_player *p, lt_u8 paused) +{ + if (!p || !p->audio || paused > 1U) return LTA_INVALID; + if (!p->active) return LTA_STATE; + { + lta_status status = lta_layer_pause(p->audio, p->effect, paused); + if (status != LTA_OK) return status; + } + p->paused = paused; + return LTA_OK; +} diff --git a/tests/stream_bench.c b/tests/stream_bench.c new file mode 100644 index 0000000..483c4a8 --- /dev/null +++ b/tests/stream_bench.c @@ -0,0 +1,82 @@ +/* SPDX-License-Identifier: MIT */ +/* Native 8088 foreground benchmark. PIT remains at BIOS mode/rate. */ +#include +#include "lt_stream.h" +#include "lt_sfx.h" +#include "../examples/stream/phrase.h" +#include "../examples/stream/chirp.h" +#define phrase phrase_plain +#define phrase_data phrase_plain_data +#include "../examples/stream/phrase_plain.h" +#undef phrase +#undef phrase_data +static lt_sfx_player effects; +static const lt_sfx_event events[] = { + {0,LTSFX_OP_TONE,1,180,0,3},{3,LTSFX_OP_TONE,1,140,0,5} +}; +static const lt_sfx_clip clip={2,2,0,2,6,0,12,60UL,1UL,events}; + +static lt_u16 pit(void); +#pragma aux pit = "pushf" "cli" "xor al,al" "out 43h,al" \ + "in al,40h" "mov ah,al" "in al,40h" "xchg al,ah" "popf" \ + value [ax] modify exact [ax] +static lt_chip chip; +static lta_handle audio; +static lt_stream_player player; +static unsigned emitted; +static void sink(void *ctx, lt_u8 byte) +{ + (void)ctx;(void)byte;++emitted; +} +static unsigned long bench(const lt_stream *s, unsigned *calls, lt_u8 effect) +{ + unsigned pass; + unsigned long total=0; + *calls=0; + for(pass=0;pass<10U;++pass) { + if(lt_stream_start(&player,s,effect)!=LTA_OK) return 0; + while(player.active) { + lt_u16 start=pit(); + if(lt_stream_tick(&player)!=LTA_OK) return 0; + total+=(lt_u16)(start-pit()); + ++*calls; + } + } + return total; +} +int main(void) +{ + unsigned i,calls; + unsigned long empty=0,total; + lta_request r; + lt_u16 start; + lt_init(&chip,sink,0);(void)lta_init(&audio,&chip,1); + lta_request_init(&r);r.op=LTA_OP_ACQUIRE;r.mask=15; + if(lta_command(&audio,&r)!=LTA_OK) return 1; + (void)lt_stream_init(&player,&audio); + for(i=0;i<1000U;++i) { start=pit();empty+=(lt_u16)(start-pit()); } + printf("probe PIT clocks=%lu calls=1000\n",empty); + total=bench(&chirp,&calls,1); + printf("chirp PIT clocks=%lu calls=%u bytes=%u\n",total,calls,(unsigned)sizeof(chirp_data)); + (void)lt_sfx_init(&effects,&audio); + total=0;calls=0; + for(i=0;i<10U;++i) { + if(lt_sfx_trigger(&effects,&clip)!=LTA_OK) return 1; + while(effects.active) { + start=pit(); + if(lt_sfx_tick(&effects)!=LTA_OK) return 1; + total+=(lt_u16)(start-pit());++calls; + } + } + printf("clip PIT clocks=%lu calls=%u event-bytes=%u\n",total,calls,(unsigned)sizeof(events)); + total=bench(&phrase_plain,&calls,0); + printf("plain-phrase PIT clocks=%lu calls=%u bytes=%u\n",total,calls,(unsigned)sizeof(phrase_plain_data)); + total=bench(&phrase,&calls,0); + printf("phrase PIT clocks=%lu calls=%u bytes=%u\n",total,calls,(unsigned)sizeof(phrase_data)); + printf("sizes handle=%u clip-player=%u descriptor=%u stream-player=%u cursor=%u\n", + (unsigned)sizeof(audio),(unsigned)sizeof(lt_sfx_player), + (unsigned)sizeof(lt_stream),(unsigned)sizeof(player), + (unsigned)sizeof(lt_stream_cursor)); + r.op=LTA_OP_RELEASE;(void)lta_command(&audio,&r); + return total ? 0 : 1; +} diff --git a/tests/stream_core.c b/tests/stream_core.c new file mode 100644 index 0000000..324e013 --- /dev/null +++ b/tests/stream_core.c @@ -0,0 +1,329 @@ +/* SPDX-License-Identifier: MIT */ +#include +#include +#include "lt_stream.h" +#include "lt_sfx.h" +#include "../examples/stream/phrase.h" +#include "../examples/stream/chirp.h" +#define phrase phrase_plain +#define phrase_data phrase_plain_data +#include "../examples/stream/phrase_plain.h" +#undef phrase +#undef phrase_data + +static lt_chip chip; +static lta_handle audio; +static lt_stream_player music, effect; +static lt_sfx_player sfx; +static unsigned failures, count; +static lt_u8 trace[4096], saved[4096]; +#define CHECK(x) do { if (!(x)) { printf("FAIL stream:%u %s\n", \ + (unsigned)__LINE__, #x); ++failures; } } while (0) +static void emit(void *ctx, lt_u8 byte) +{ + (void)ctx; + if (count < sizeof(trace)) trace[count++] = byte; + else ++failures; +} +static lta_status command(lt_u8 op, lt_u8 ch, lt_u16 period, lt_u8 level) +{ + lta_request r; + lta_request_init(&r); + r.op = op; r.channel = ch; r.period = period; r.level = level; + r.mask = 15U; + return lta_command(&audio, &r); +} +static void setup(void) +{ + lt_init(&chip, emit, 0); + CHECK(lta_init(&audio, &chip, 1U) == LTA_OK); + CHECK(command(LTA_OP_ACQUIRE, 0, 0, 0) == LTA_OK); + CHECK(lt_stream_init(&music, &audio) == LTA_OK); + CHECK(lt_stream_init(&effect, &audio) == LTA_OK); + CHECK(lt_sfx_init(&sfx, &audio) == LTA_OK); + count = 0U; +} +static const lt_u8 packed[] = { + 0x10, 100,0,2, 1,2,0, 0x10,120,0,4, 1,2,0, + 2,0,0,14,0,7, 0 +}; +static const lt_u8 plain[] = { + 0x10,100,0,2,1,2,0,0x10,120,0,4,1,2,0, + 0x10,100,0,2,1,2,0,0x10,120,0,4,1,2,0, + 0x10,100,0,2,1,2,0,0x10,120,0,4,1,2,0, + 0x10,100,0,2,1,2,0,0x10,120,0,4,1,2,0, + 0x10,100,0,2,1,2,0,0x10,120,0,4,1,2,0, + 0x10,100,0,2,1,2,0,0x10,120,0,4,1,2,0, + 0x10,100,0,2,1,2,0,0x10,120,0,4,1,2,0, + 0x10,100,0,2,1,2,0,0x10,120,0,4,1,2,0,0 +}; +static const lt_sfx_event events[] = { + {0, LTSFX_OP_TONE, 0, 80,0,3}, + {1, LTSFX_OP_TONE, 0, 90,0,5} +}; +static const lt_sfx_clip clip = {2,1,2,2,2,0,12,60UL,1UL,events}; +static lt_stream descriptor(const lt_u8 *data, lt_u16 bytes) +{ + lt_stream s; + s.data = data; s.bytes = bytes; s.version = 1U; s.chip = 1U; + s.channel_mask = 1U; s.priority = 2U; + s.clock_hz = 3579545UL; s.rate_num = 60UL; s.rate_den = 1UL; + return s; +} +static void traces(void) +{ + lt_stream s = descriptor(packed, sizeof(packed)); + unsigned length, i; + setup(); + CHECK(lt_stream_start(&music, &s, 0) == LTA_OK); + for (i = 0; i < 33U; ++i) CHECK(lt_stream_tick(&music) == LTA_OK); + CHECK(!music.active); + length = count; memcpy(saved, trace, count); + setup(); s.data = plain; s.bytes = sizeof(plain); + CHECK(lt_stream_start(&music, &s, 0) == LTA_OK); + for (i = 0; i < 33U; ++i) CHECK(lt_stream_tick(&music) == LTA_OK); + CHECK(count == length && memcmp(saved, trace, count) == 0); + CHECK(lt_stream_start(&effect, &s, 1) == LTA_OK); + CHECK(lt_stream_tick(&effect) == LTA_OK); + CHECK(lt_stream_pause(&effect, 1) == LTA_OK); + length = count; + CHECK(lt_stream_tick(&effect) == LTA_OK && count == length); + CHECK(lt_stream_pause(&effect, 0) == LTA_OK); + CHECK(lt_stream_stop(&effect) == LTA_OK); + CHECK(lt_stream_pause(&effect, 0) == LTA_STATE); +} +static void generated_assets(void) +{ + unsigned i, length; + setup(); + CHECK(lt_stream_start(&music,&phrase,0)==LTA_OK); + for(i=0;i<481U;++i) CHECK(lt_stream_tick(&music)==LTA_OK); + CHECK(!music.active); + length=count;memcpy(saved,trace,count); + setup(); + CHECK(lt_stream_start(&music,&phrase_plain,0)==LTA_OK); + for(i=0;i<481U;++i) CHECK(lt_stream_tick(&music)==LTA_OK); + CHECK(count==length && memcmp(saved,trace,count)==0); + CHECK(lt_stream_start(&effect,&chirp,1)==LTA_OK); + CHECK(lt_stream_tick(&effect)==LTA_OK); + CHECK(lt_stream_pause(&effect,1)==LTA_OK && chip.attenuation[1]==15); + CHECK(lt_stream_pause(&effect,0)==LTA_OK && chip.attenuation[1]==3); + CHECK(lt_stream_stop(&effect)==LTA_OK); +} +static void attenuation_and_loops(void) +{ + unsigned i, before; + setup(); + CHECK(command(LTA_OP_TONE_START,0,100,2) == LTA_OK); + CHECK(lta_music_attenuation(&audio,0,4) == LTA_OK); + CHECK(chip.attenuation[0] == 6 && audio.level[0] == 2); + CHECK(lta_music_attenuation(&audio,0,15) == LTA_OK); + CHECK(chip.attenuation[0] == 15 && audio.level[0] == 2); + CHECK(lta_music_attenuation(&audio,0,0) == LTA_OK); + CHECK(chip.attenuation[0] == 2); + CHECK(lt_sfx_loop(&sfx,&clip,0) == LTA_OK); + CHECK(lt_sfx_tick(&sfx) == LTA_OK); + CHECK(lta_sfx_attenuation(&audio,4) == LTA_OK); + CHECK(chip.attenuation[0] == 7 && audio.effect_level[0] == 3); + before = count; + CHECK(lta_music_attenuation(&audio,0,5) == LTA_OK && count == before); + CHECK(command(LTA_OP_TONE_UPDATE,0,333,6) == LTA_OK); + CHECK(audio.period[0] == 333 && chip.period[0] == 80); + CHECK(lt_sfx_pause(&sfx,1) == LTA_OK); + for (i=0;i<10U;++i) CHECK(lt_sfx_tick(&sfx) == LTA_OK); + CHECK(sfx.elapsed == 1U); + CHECK(lt_sfx_pause(&sfx,0) == LTA_OK); + for (i=0;i<10U;++i) CHECK(lt_sfx_tick(&sfx) == LTA_OK); + CHECK(sfx.active); + CHECK(lta_sfx_attenuation(&audio,15) == LTA_OK); + CHECK(chip.attenuation[0] == 15); + CHECK(lt_sfx_cancel(&sfx) == LTA_OK); + for (i=0;i<3U;++i) CHECK(lt_sfx_tick(&sfx) == LTA_OK); + CHECK(!sfx.active && chip.period[0] == 333 && chip.attenuation[0] == 11); + CHECK(trace[count-3] == (lt_u8)(0x80U | (333U & 15U))); + CHECK(trace[count-2] == (333U >> 4) && trace[count-1] == 0x9BU); + CHECK(lt_sfx_loop(&sfx,&clip,3) == LTA_OK); + for (i=0;i<6U;++i) CHECK(lt_sfx_tick(&sfx) == LTA_OK); + CHECK(sfx.active && sfx.plays_left == 1); + CHECK(lt_sfx_tick(&sfx) == LTA_OK && !sfx.active); + CHECK(lt_sfx_loop(&sfx,&clip,65535U) == LTA_OK); + for (i=0;i<3U;++i) CHECK(lt_sfx_tick(&sfx) == LTA_OK); + CHECK(sfx.plays_left == 65534U); + CHECK(lt_sfx_stop(&sfx) == LTA_OK); + CHECK(lt_sfx_pause(&sfx,0) == LTA_STATE); + CHECK(lta_music_attenuation(&audio,0,16) == LTA_INVALID); + CHECK(lta_sfx_attenuation(&audio,16) == LTA_INVALID); +} +static void malformed(void) +{ + lt_u8 data[600]; + lt_stream s = descriptor(data, sizeof(packed)); + unsigned i; + memcpy(data,packed,sizeof(packed)); + for (i=0;i sources. DOS_DEMOS = { + 'STRMDEMO': ['examples/stream_demo.c', 'src/lt_chip.c', 'src/lt_audio.c', + 'src/lt_sfx.c', 'src/lt_stream.c', 'adapters/lt_dos.c'], + 'STRMBEN': ['tests/stream_bench.c', 'src/lt_chip.c', 'src/lt_audio.c', + 'src/lt_sfx.c', 'src/lt_stream.c'], 'SMSBOOT': ['examples/sms_demo.c', 'src/lt_chip.c', 'src/lt_sms_boot.c', 'adapters/lt_dos.c', 'adapters/lt_tick_dos.c'], 'DOEBEL': ['examples/doebel_demo.c', 'src/lt_chip.c', diff --git a/tools/compile_stream.py b/tools/compile_stream.py new file mode 100644 index 0000000..62e3b77 --- /dev/null +++ b/tools/compile_stream.py @@ -0,0 +1,101 @@ +"""Compile original logical PSG JSON to an immutable C89 lt_stream descriptor. + +Developer tool only. Consumers compile the generated header, needing no Python. +Commands: ["tone", channel, period, level], ["noise", mode, level], +["level", channel, level], ["stop", mask], ["wait", ticks]. +Compression refers only to previously emitted whole literal commands, depth one. +The runtime validator remains authoritative for timing/ownership/step limits. +""" +import argparse +import json +from pathlib import Path +import re + + +def word(n): + if not 0 <= n <= 65535: + raise ValueError('16-bit field out of range') + return bytes((n & 255, n >> 8)) + + +def encode(c): + op, *a = c + if op == 'tone' and len(a) == 3 and 0 <= a[0] <= 2 and 0 <= a[1] <= 1023 and 0 <= a[2] <= 15: + return bytes((0x10 + a[0],)) + word(a[1]) + bytes((a[2],)) + if op == 'noise' and len(a) == 2 and 0 <= a[0] <= 7 and 0 <= a[1] <= 15: + return bytes((0x13, *a)) + if op == 'level' and len(a) == 2 and 0 <= a[0] <= 3 and 0 <= a[1] <= 15: + return bytes((0x20 + a[0], a[1])) + if op == 'stop' and len(a) == 1 and 1 <= a[0] <= 15: + return bytes((0x30, a[0])) + if op == 'wait' and len(a) == 1 and 1 <= a[0] <= 65535: + return b'\x01' + word(a[0]) + raise ValueError('Invalid command: ' + repr(c)) + + +def compile_commands(commands, compress=True): + records = [encode(c) for c in commands] + out = bytearray() + # Literal runs only: references can never refer to other references. + runs, run, i = [], [], 0 + while i < len(records): + best = None + if compress: + for literals in runs + ([run] if run else []): + for start in range(len(literals)): + for end in range(start + 1, len(literals) + 1): + block = [r for _, r in literals[start:end]] + size = sum(map(len, block)) + count = 0 + while count < 255 and records[i + count * len(block):i + (count + 1) * len(block)] == block: + count += 1 + saving = size * count - 6 + if saving > 0 and (best is None or saving > best[0]): + best = saving, literals[start][0], size, count, len(block) + if best: + _, offset, size, count, length = best + out += b'\x02' + word(offset) + word(size) + bytes((count,)) + if run: + runs.append(run) + run = [] + i += count * length + else: + run.append((len(out), records[i])) + out += records[i] + i += 1 + out += b'\x00' + if len(out) > 32767: + raise ValueError('Near stream exceeds 32767 bytes') + return bytes(out), sum(map(len, records)) + 1 + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('source', type=Path) + p.add_argument('output', type=Path) + p.add_argument('--plain', action='store_true') + a = p.parse_args() + source = json.loads(a.source.read_text()) + name = source['name'] + if not re.fullmatch('[a-z][a-z0-9_]*', name): + raise ValueError('C identifier required') + data, plain = compile_commands(source['commands'], not a.plain) + mask, priority = source['channel_mask'], source.get('priority', 0) + if not 1 <= mask <= 15 or not 0 <= priority <= 255: + raise ValueError('Invalid channel mask or priority') + clock, num, den = source['clock_hz'], source['rate_num'], source['rate_den'] + if not all(1 <= n <= 0xffffffff for n in (clock, num, den)): + raise ValueError('Invalid metadata') + lines = ['/* SPDX-License-Identifier: MIT */', '/* Generated from ' + a.source.name + '; no runtime conversion. */', '#include "lt_stream.h"', + 'static const lt_u8 ' + name + '_data[] = {'] + lines += [' ' + ','.join('0x%02X' % b for b in data[i:i+12]) + ',' for i in range(0, len(data), 12)] + lines += ['};', 'static const lt_stream ' + name + ' = {', + ' ' + name + '_data, sizeof(' + name + '_data),', + f' LTST_VERSION, LTST_TANDY, {mask}, {priority},', + f' {clock}UL, {num}UL, {den}UL', '};', ''] + a.output.write_text('\n'.join(lines)) + print(f'{name}: plain={plain} packed={len(data)} bytes (descriptor excluded)') + + +if __name__ == '__main__': + main() diff --git a/tools/measure_audio_omf.py b/tools/measure_audio_omf.py new file mode 100644 index 0000000..b1b150f --- /dev/null +++ b/tools/measure_audio_omf.py @@ -0,0 +1,45 @@ +"""Report actual Open Watcom 16-bit OMF segment bytes (not object file size). + +Usage: python tools/measure_audio_omf.py build/dos/lt_audio.obj ... +""" +import json +from pathlib import Path +import sys + + +def index(data, pos): + number = data[pos] + pos += 1 + if number & 128: + number = ((number & 127) << 8) | data[pos] + pos += 1 + return number, pos + + +def segments(path): + blob, pos, names, result = path.read_bytes(), 0, [''], [] + while pos < len(blob): + kind = blob[pos] + length = int.from_bytes(blob[pos+1:pos+3], 'little') + data = blob[pos+3:pos+2+length] # checksum excluded + pos += 3 + length + if kind == 0x96: # LNAMES + at = 0 + while at < len(data): + size = data[at] + at += 1 + names.append(data[at:at+size].decode('ascii')) + at += size + elif kind == 0x98: # 16-bit SEGDEF + at = 4 if data[0] >> 5 == 0 else 1 + size = int.from_bytes(data[at:at+2], 'little') + if data[0] & 2: + size = 65536 + name, at = index(data, at+2) + cls, at = index(data, at) + result.append(dict(name=names[name], segment_class=names[cls], bytes=size)) + return result + + +if __name__ == '__main__': + print(json.dumps({name: segments(Path(name)) for name in sys.argv[1:]}, indent=2))