Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions docs/COMPACT-AUDIO-VALIDATION.md
Original file line number Diff line number Diff line change
@@ -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.
125 changes: 125 additions & 0 deletions docs/COMPACT-AUDIO.md
Original file line number Diff line number Diff line change
@@ -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.
112 changes: 112 additions & 0 deletions examples/stream/COSTS.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
5 changes: 5 additions & 0 deletions examples/stream/DEMO-BUILD.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"bytes": 20796,
"sha256": "66451bdcf7c08f50bd695ace37f99beabb1e1198515cee0ad436ad24773b8782",
"dos_run": "STRMDEMO /auto"
}
10 changes: 10 additions & 0 deletions examples/stream/DEMO-NATIVE.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading