mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 13:38:30 +09:00
Compare commits
105 Commits
937d3e27ed
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e27a01dca6 | ||
|
|
35263eeaa4 | ||
|
|
d223adda25 | ||
|
|
a9d095e3cb | ||
|
|
dad345c027 | ||
|
|
2045da0286 | ||
|
|
3362a6b732 | ||
|
|
038db60b59 | ||
|
|
1d3b5ce8aa | ||
|
|
9e8af96c32 | ||
|
|
43e5baadf4 | ||
|
|
f863f6230d | ||
|
|
d8ac08162c | ||
|
|
e24870ce07 | ||
|
|
10e577699f | ||
|
|
01cc5c90ee | ||
|
|
051177f7f7 | ||
|
|
5f873fa2d1 | ||
|
|
a7db53e81c | ||
|
|
8d473c223c | ||
|
|
5a25d394b9 | ||
|
|
15587a0d76 | ||
|
|
a716807b36 | ||
|
|
b103e3c690 | ||
|
|
7edc3e32b1 | ||
|
|
6db6a2e7ed | ||
|
|
0d564d5f82 | ||
|
|
6d20d346f5 | ||
|
|
de82435f6e | ||
|
|
054295fdab | ||
|
|
26303c63af | ||
|
|
2ff471a066 | ||
|
|
dfcc0c7729 | ||
|
|
4e7fe82690 | ||
|
|
13eaf1b999 | ||
|
|
6623ff62bc | ||
|
|
3c43aa8aa6 | ||
|
|
848ee491d1 | ||
|
|
eddd65fa13 | ||
|
|
1e2814af87 | ||
|
|
61a721d628 | ||
|
|
9723c33dfc | ||
|
|
065e586cd6 | ||
|
|
83d9cde0bd | ||
|
|
0b82d4b32c | ||
|
|
277693989b | ||
|
|
db3ffdedb6 | ||
|
|
5b9b96c8de | ||
|
|
8e8374ba99 | ||
|
|
34fba4b2f2 | ||
|
|
1d28c89937 | ||
|
|
61524b3685 | ||
|
|
e6f77c4789 | ||
|
|
00c0e18c1a | ||
|
|
135c7b9c4e | ||
|
|
295c1f7fe2 | ||
|
|
e74a373605 | ||
|
|
b1a0a9f801 | ||
|
|
bdc2578072 | ||
|
|
e3bd4a1b59 | ||
|
|
70d953a784 | ||
|
|
f3ece28a10 | ||
|
|
3ecf842ac0 | ||
|
|
6004060344 | ||
|
|
7d89605302 | ||
|
|
11bc1ca125 | ||
|
|
6a72a81198 | ||
|
|
8380d1e845 | ||
|
|
4ea9ade060 | ||
|
|
46ae6511f6 | ||
|
|
577d46d31e | ||
|
|
2ffdf32c91 | ||
|
|
a28fcbcefc | ||
|
|
ebba33a5c3 | ||
|
|
ab0b215759 | ||
|
|
2c2ad70a23 | ||
|
|
fb42ab4413 | ||
|
|
2177ddbd6b | ||
|
|
aa32c70d8a | ||
|
|
72761c0552 | ||
|
|
2cdd731c3b | ||
|
|
b27ef0dbf9 | ||
|
|
ddeab1c782 | ||
|
|
f69108c40d | ||
|
|
74cba0a893 | ||
|
|
bc235ebb17 | ||
|
|
9f01bdfee9 | ||
|
|
3c57e33f8f | ||
|
|
935fbe04a6 | ||
|
|
6b02d73600 | ||
|
|
8e6f597e9b | ||
|
|
ed3bbb6ffe | ||
|
|
27b0f2e63f | ||
|
|
dcd191b734 | ||
|
|
d706f27e18 | ||
|
|
e49140902b | ||
|
|
3182ae9146 | ||
|
|
34b3b83d65 | ||
|
|
a767eebc2e | ||
|
|
6ce8d2cc1e | ||
|
|
9017b76f6d | ||
|
|
449885c1ea | ||
|
|
59bbe9e503 | ||
|
|
cc492c4ead | ||
|
|
ec0f41b574 |
12
.gitignore
vendored
12
.gitignore
vendored
@@ -62,11 +62,15 @@ tsvmman.pdf
|
||||
*.ilg
|
||||
*.ind
|
||||
|
||||
assets/disk0/tvdos/bin/tautfont.png
|
||||
|
||||
video_encoder/*
|
||||
|
||||
.idea/vcs.xml
|
||||
|
||||
# in-dev stuffs
|
||||
assets/disk0/home/basic/*
|
||||
assets/disk0/movtestimg/*.jpg
|
||||
assets/disk0/*.mov
|
||||
assets/diskMediabin/*
|
||||
|
||||
video_encoder/*
|
||||
|
||||
assets/disk0/tvdos/bin/tautfont.png
|
||||
assets/disk0/hopper/*
|
||||
|
||||
11
.idea/libraries/badlogicgames_gdx.xml
generated
Normal file
11
.idea/libraries/badlogicgames_gdx.xml
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
<component name="libraryTable">
|
||||
<library name="badlogicgames.gdx" type="repository">
|
||||
<properties maven-id="com.badlogicgames.gdx:gdx:1.12.1" />
|
||||
<CLASSES>
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/gdx/gdx/1.12.1/gdx-1.12.1.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/gdx/gdx-jnigen-loader/2.3.1/gdx-jnigen-loader-2.3.1.jar!/" />
|
||||
</CLASSES>
|
||||
<JAVADOC />
|
||||
<SOURCES />
|
||||
</library>
|
||||
</component>
|
||||
62
.idea/libraries/badlogicgames_gdx_backend_lwjgl3.xml
generated
Normal file
62
.idea/libraries/badlogicgames_gdx_backend_lwjgl3.xml
generated
Normal file
@@ -0,0 +1,62 @@
|
||||
<component name="libraryTable">
|
||||
<library name="badlogicgames.gdx.backend.lwjgl3" type="repository">
|
||||
<properties maven-id="com.badlogicgames.gdx:gdx-backend-lwjgl3:1.12.1" />
|
||||
<CLASSES>
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/gdx/gdx-backend-lwjgl3/1.12.1/gdx-backend-lwjgl3-1.12.1.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/gdx/gdx/1.12.1/gdx-1.12.1.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/gdx/gdx-jnigen-loader/2.3.1/gdx-jnigen-loader-2.3.1.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/jlayer/jlayer/1.0.1-gdx/jlayer-1.0.1-gdx.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jcraft/jorbis/0.0.17/jorbis-0.0.17.jar!/" />
|
||||
</CLASSES>
|
||||
<JAVADOC />
|
||||
<SOURCES />
|
||||
</library>
|
||||
</component>
|
||||
16
2taud.sh
16
2taud.sh
@@ -1,8 +1,12 @@
|
||||
#!/usr/bin/env fish
|
||||
|
||||
for f in *.mod; python3 mod2taud.py $f assets/disk0/(basename $f .mod).taud; end
|
||||
for f in *.s3m; python3 s3m2taud.py $f assets/disk0/(basename $f .s3m).taud; end
|
||||
for f in *.it; python3 it2taud.py $f assets/disk0/(basename $f .it).taud; end
|
||||
for f in *.xm; python3 xm2taud.py $f assets/disk0/(basename $f .xm).taud; end
|
||||
for f in *.mon; python3 mon2taud.py $f assets/disk0/(basename $f .mon).taud; end
|
||||
for f in *.MON; python3 mon2taud.py $f assets/disk0/(basename $f .MON).taud; end
|
||||
for f in *.mod; python3 mod2taud.py $f assets/disk0/home/music/(basename $f .mod).taud; end
|
||||
for f in *.MOD; python3 mod2taud.py $f assets/disk0/home/music/(basename $f .MOD).taud; end
|
||||
for f in *.s3m; python3 s3m2taud.py $f assets/disk0/home/music/(basename $f .s3m).taud; end
|
||||
for f in *.S3M; python3 s3m2taud.py $f assets/disk0/home/music/(basename $f .S3M).taud; end
|
||||
for f in *.it; python3 it2taud.py $f assets/disk0/home/music/(basename $f .it).taud; end
|
||||
for f in *.IT; python3 it2taud.py $f assets/disk0/home/music/(basename $f .IT).taud; end
|
||||
for f in *.xm; python3 xm2taud.py $f assets/disk0/home/music/(basename $f .xm).taud; end
|
||||
for f in *.XM; python3 xm2taud.py $f assets/disk0/home/music/(basename $f .XM).taud; end
|
||||
for f in *.mon; python3 mon2taud.py $f assets/disk0/home/music/(basename $f .mon).taud; end
|
||||
for f in *.MON; python3 mon2taud.py $f assets/disk0/home/music/(basename $f .MON).taud; end
|
||||
|
||||
128
CLAUDE.md
128
CLAUDE.md
@@ -37,6 +37,7 @@ Current topics:
|
||||
- `reference_materials/impulse-tracker` — The original source code for ImpulseTracker
|
||||
- `reference_materials/MilkyTracker` — FastTracker 2 compatible tracker
|
||||
- `reference_materials/schismtracker` — Open-source re-implementation of ImpulseTracker
|
||||
- `reference_materials/pt2-clone` — Open-source re-implementation of ProTracker 2
|
||||
|
||||
When fetching new references, copy the relevant upstream files verbatim into
|
||||
a topic folder, write a `README.md` summarising the relevant maths /
|
||||
@@ -115,6 +116,16 @@ Use the build scripts in `buildapp/`:
|
||||
- `My_BASIC_Programs/`: Example BASIC programs for testing
|
||||
- TVDOS filesystem uses custom format with specialised drivers
|
||||
|
||||
### TSVM JavaScript Source Encoding
|
||||
|
||||
**Do not normalise `\uXXXX` or `\xXX` escapes in .js / .mjs files that run inside
|
||||
TSVM.** TSVM's character set is not Unicode, and the JS string literal parser
|
||||
behaves differently for raw bytes vs. escape sequences. Both forms appear in
|
||||
existing code intentionally — leave each one as-is. When writing new content,
|
||||
prefer raw UTF-8 characters in string literals (e.g. write the character `ù`
|
||||
directly, rather than a `\uXXXX`-style escape) unless you are matching a
|
||||
pattern already established in the surrounding code.
|
||||
|
||||
## Videotron2K
|
||||
|
||||
The Videotron2K is a specialised video display controller with:
|
||||
@@ -154,6 +165,14 @@ Peripheral memories can be accessed using `vm.peek()` and `vm.poke()` functions,
|
||||
|
||||
- The 'gzip' namespace in TSVM's JS programs is a misnomer: the actual 'gzip' functions (defined in CompressorDelegate.kt) call Zstd functions.
|
||||
|
||||
## Taud Tracker Engine
|
||||
|
||||
The Taud playback engine lives in `tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt`.
|
||||
|
||||
### Critical Implementation Notes
|
||||
|
||||
**Re-bind the local `inst` after any mid-tick `triggerNote`.** `applyTrackerTick` binds `var inst = instruments[voice.instrumentId]` once at the top of the per-voice loop. When the note-delay (`S$Dx`) deferred trigger fires mid-tick, `triggerNote` swaps the voice's `instrumentId` — but the rest of that tick (playback-rate recompute at the `computePlaybackRate(inst, finalPitch)` line, `advanceEnvelope`, `advancePfEnvelope`, `advanceAutoVibrato`, and the fadeout / filter-env reads of `inst.*`) keeps using the captured binding. The damage on a **never-triggered voice** (`instrumentId == 0` → stale `inst = instruments[0]`, whose `samplingRate == 0`) is that `playbackRate` is overwritten with `0.0`, freezing the sample at its start for the trigger tick — perceived as "the first delayed note on a fresh channel doesn't fire" (canonical: WHEN.taud cue 0 voice 13 pattern 0x0A row 16, inst `0x11` SD2 on a fresh play). On a warm voice the stale `inst` is a real instrument with non-zero rate, so the note sounds (at the wrong rate for one tick — a sub-perceptual glitch). Re-bind `inst = instruments[voice.instrumentId]` immediately after the note-delay fire block. Any future in-tick trigger paths (currently only S$Dx) must do the same.
|
||||
|
||||
## TVDOS
|
||||
|
||||
### TVDOS Movie Formats
|
||||
@@ -417,3 +436,112 @@ The different weights for Mid and Side channels reflect the perceptual importanc
|
||||
- DC frequency underamplification (using 1.0 instead of 4.0/6.0)
|
||||
- Incorrect stereo imaging and extreme side channel distortion
|
||||
- Severe frequency response errors that manifest as "clipping-like" distortion
|
||||
|
||||
## Virtual Consoles (vtmgr)
|
||||
|
||||
Linux-style virtual consoles for TVDOS: up to 6 independent shell sessions,
|
||||
switched with **Alt-1..Alt-6** or the **`chvt N`** builtin, **Alt-0** to exit.
|
||||
Implemented entirely in JS — **no tsvm_core changes**.
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Dispatcher**: `assets/disk0/tvdos/sbin/vtmgr.js`. Launched directly by the
|
||||
`TVDOS.SYS` boot block (only when `!_TVDOS_IS_VT_PANE`); when it exits (Alt-0)
|
||||
the boot block runs `AUTOEXEC.BAT` as the bare fallback shell. Owns the
|
||||
physical keyboard and screen. Each VT runs in its own GraalVM context/thread
|
||||
via the existing `parallel.spawnNewContext` / `attachProgram` / `launch` API
|
||||
(see `VMJSR223Delegate.kt` `class Parallel`). VT 1 spawns at boot; VT 2-6 are
|
||||
lazy-spawned on first switch and re-spawned if their shell exits.
|
||||
- **Concurrency model**: truly concurrent — switching works mid-command, not
|
||||
just at the prompt. Background panes keep running (no `Thread.suspend`; it is
|
||||
unusable on JDK 21). A cooperative gate inside the shimmed `con.getch` parks
|
||||
panes blocked on input; CPU-bound background panes are allowed to run.
|
||||
- **Shared memory**: one `sys.malloc` region holds a control block (active VT,
|
||||
switch request, debounce, spawned-bits) plus, per VT, an input ring buffer and
|
||||
a 7682-byte text-plane buffer mirroring the GPU text-area layout
|
||||
(cursor 2 + fore 2560 + back 2560 + char 2560).
|
||||
- **Compositor** (30 Hz): blits the active VT's text plane to the physical GPU
|
||||
text area via `sys.memcpy`, and pushes that VT's cursor-visibility into the GPU
|
||||
blink bit (MMIO attribute byte 6, addressed at `-1 - (131072*gpuSlot + 6)`).
|
||||
- **Boot config split (`commandrc` + `AUTOEXEC.BAT`)**: environment setup and
|
||||
app-launch are split into two files so panes can replay one without the other.
|
||||
`\commandrc` holds the `set` commands (PATH/INCLPATH/HELPPATH/KEYBOARD) and is
|
||||
run by the `TVDOS.SYS` boot block in **every** context (boot and pane) — it has
|
||||
no `.BAT` extension, so the boot block runs it line-by-line (`set` mutates the
|
||||
shared `_TVDOS.variables`, so the effect persists). `\AUTOEXEC.BAT` is the
|
||||
**per-console launch** script (Korean IME `tvdos/i18n/korean`, then
|
||||
`command -fancy`); it is run once per console — by each pane's bootstrap, and
|
||||
by the boot block as the post-vtmgr fallback. No env snapshot/replay anymore;
|
||||
each pane gets PATH/KEYBOARD/etc. natively from `commandrc`, and Korean IME
|
||||
(a per-context `unicode.uniprint` handler) now registers in every pane.
|
||||
- **Per-pane bootstrap**: each pane re-evals `TVDOS.SYS` (with `_TVDOS_IS_VT_PANE`
|
||||
set — which makes the boot block run `commandrc` but skip the vtmgr/AUTOEXEC
|
||||
launch — and a `_BIOS` stub captured live from the main context) then runs
|
||||
`command -c \AUTOEXEC.BAT`, all in ONE direct `eval` so the launcher shares
|
||||
scope with `_TVDOS`/`files`/`execApp`.
|
||||
|
||||
### Output/input shimming (in the pane bootstrap)
|
||||
|
||||
`con` and the global `print`/`println` family are plain JS, so the bootstrap
|
||||
overrides them to read/write the per-VT shared-memory buffers instead of the
|
||||
physical GPU. **`sys` and `graphics` are host objects and CANNOT be overridden
|
||||
from JS** — this is the key constraint that shapes everything below.
|
||||
|
||||
- The shimmed `print` is a faithful JS port of the GPU's TTY interpreter
|
||||
(`GlassTty.acceptChar` + `GraphicsAdapter` handlers): control bytes, the
|
||||
`\x84<decimal>u` "emit char by code" escape (used by `con.prnch`), CSI cursor
|
||||
moves / erase / SGR colours, and the `?25` cursor-visibility private sequence.
|
||||
A swallow-only parser is NOT enough — TVDOS apps drive the screen through
|
||||
these `print` escapes.
|
||||
- `con.move`/`con.getyx` are **1-based** (mirroring `graphics.setCursorYX`'s
|
||||
`cx-1` and `getCursorYX`'s `cx+1`); `con.addch` does NOT advance the cursor
|
||||
(matches `graphics.putSymbol`), while `con.prnch` DOES.
|
||||
- `command.js`'s `shell.execute` reassigns the global print family to
|
||||
`shell.stdio.out.*`, which call `sys.print` (→ physical GPU). `shell.stdio.out`
|
||||
was made to delegate to a `globalThis.__VT_OUT` hook when present (set by the
|
||||
bootstrap); outside a VT the hook is absent and the path is byte-identical.
|
||||
|
||||
### Direct-VRAM apps need a VT-aware base (the `vaddr` pattern)
|
||||
|
||||
Apps that write the text area directly via `graphics.getGpuMemBase()` (rather
|
||||
than `con.*`/`print`) bypass the shims and paint the physical screen, invading
|
||||
whatever VT is visible. They must resolve text-area byte `m` through a
|
||||
VT-aware base:
|
||||
|
||||
```js
|
||||
// physical: backward (byte m at gpuBase - m) — getDev inverts to forward-native
|
||||
// VT pane: forward (byte m at VT_TEXT_PLANE + m, the pane buffer the compositor blits)
|
||||
const VT = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||
const VRAM_BASE = VT ? globalThis.VT_TEXT_PLANE : (graphics.getGpuMemBase() - 253950)
|
||||
const VRAM_SGN = VT ? 1 : -1
|
||||
function vaddr(m) { return VRAM_BASE + VRAM_SGN * m }
|
||||
```
|
||||
|
||||
`sys.memcpy`/`sys.pokeBytes` copy forward in the resolved native memory, so this
|
||||
works for both directions. The physical branch is identical to the original
|
||||
arithmetic (no regression outside vtmgr). Applied so far in
|
||||
`assets/disk0/tvdos/bin/taut.js` and `assets/disk0/hopper/include/aa.mjs`
|
||||
(used by `bb.js`). Any future direct-VRAM app needs the same one-line `vaddr`.
|
||||
|
||||
### Files
|
||||
|
||||
- New: `assets/disk0/tvdos/sbin/vtmgr.js` (dispatcher + per-pane bootstrap)
|
||||
- `assets/disk0/tvdos/bin/command.js`: `chvt` builtin, `[N]` prompt prefix for
|
||||
VT 2-6, `shell.stdio.out` → `__VT_OUT` delegation
|
||||
- `assets/disk0/tvdos/TVDOS.SYS`: boot block runs `\commandrc` (env) in every
|
||||
context, then — only when `!_TVDOS_IS_VT_PANE` — launches `tvdos/sbin/vtmgr`
|
||||
and, on its exit, `\AUTOEXEC.BAT` as the fallback shell
|
||||
- `assets/disk0/commandrc`: env-only `set` commands (PATH/INCLPATH/HELPPATH/KEYBOARD)
|
||||
- `assets/disk0/AUTOEXEC.BAT`: per-console launch (Korean IME + `command -fancy`)
|
||||
- `assets/disk0/tvdos/bin/taut.js`, `assets/disk0/hopper/include/aa.mjs`:
|
||||
`vaddr` VT-aware direct-VRAM addressing
|
||||
|
||||
### Gotcha: injectIntChk vs. embedded source
|
||||
|
||||
`execApp`/`require` run a program's source through `injectIntChk` (TVDOS.SYS),
|
||||
which sed-rewrites the **first** `while`/`for`/`do` of each kind to call a
|
||||
per-exec `tvdosSIGTERM_<hash>()` SIGTERM check. When vtmgr embeds the pane
|
||||
bootstrap as a string literal, one of those rewrites can land inside the literal
|
||||
— and the pane context has no such symbol. vtmgr strips them from the bootstrap
|
||||
string with `raw.replace(/tvdosSIGTERM_[A-Za-z0-9_]+\(\);?/g, '')`. Any future
|
||||
code that builds executable source as a string literal must do the same.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,5 +10,7 @@
|
||||
<orderEntry type="module" module-name="tsvm_core" />
|
||||
<orderEntry type="library" name="TerranVirtualDisk" level="project" />
|
||||
<orderEntry type="library" name="lib" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx.backend.lwjgl3" level="project" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -1 +1 @@
|
||||
let p=_BIOS.FIRST_BOOTABLE_PORT;com.sendMessage(p[0],"DEVRST\x17");com.sendMessage(p[0],'OPENR"tvdos/hyve.SYS",'+p[1]);let r=com.getStatusCode(p[0]);if(0==r)if(com.sendMessage(p[0],"READ"),r=com.getStatusCode(p[0]),0==r){let g=com.pullMessage(p[0]);eval(g)}else println("I/O Error");else println("TVDOS.SYS not found");println("Shutting down...");println("It is now safe to turn off the power")
|
||||
let p=_BIOS.FIRST_BOOTABLE_PORT;com.sendMessage(p[0],"DEVRST\x17");com.sendMessage(p[0],'OPENR"tvdos/TVDOS.SYS",'+p[1]);let r=com.getStatusCode(p[0]);if(0==r)if(com.sendMessage(p[0],"READ"),r=com.getStatusCode(p[0]),0==r){let g=com.pullMessage(p[0]);eval(g)}else println("I/O Error");else println("TVDOS.SYS not found");println("Shutting down...");println("It is now safe to turn off the power")
|
||||
@@ -1,10 +1,11 @@
|
||||
echo "Starting TVDOS..."
|
||||
|
||||
rem put set-xxx commands here:
|
||||
set PATH=\tvdos\installer;\tvdos\tuidev;$PATH
|
||||
set KEYBOARD=us_colemak
|
||||
|
||||
rem this line specifies which shell to be presented after the boot precess:
|
||||
rem AUTOEXEC.BAT -- per-console launch script. Run once for every console:
|
||||
rem each virtual-console pane runs it (via vtmgr's bootstrap), and the boot
|
||||
rem shell runs it as the fallback once vtmgr exits (Alt-0). Environment setup
|
||||
rem (`set` commands) lives in \commandrc, which TVDOS.SYS runs before this.
|
||||
rem
|
||||
rem Korean IME registers a per-CONTEXT handler (unicode.uniprint), so it must
|
||||
rem run per-console here rather than once at boot.
|
||||
tvdos/i18n/korean
|
||||
zfm
|
||||
|
||||
rem The interactive shell for this console.
|
||||
command -fancy
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2020-2024 CuriousTorvald
|
||||
Copyright (c) 2020-2026 CuriousTorvald
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
9
assets/disk0/commandrc
Normal file
9
assets/disk0/commandrc
Normal file
@@ -0,0 +1,9 @@
|
||||
rem commandrc -- environment setup, run by TVDOS.SYS in EVERY context
|
||||
rem (the boot shell AND every virtual-console pane). Put `set` commands and
|
||||
rem other env-only configuration here. Do NOT launch apps from this file:
|
||||
rem app launches belong in AUTOEXEC.BAT (run per-console by vtmgr).
|
||||
|
||||
set PATH=\tvdos\installer;\tvdos\tuidev;\tbas;\hopper\bin;$PATH
|
||||
set INCLPATH=\hopper\include;$INCLPATH
|
||||
set HELPPATH=\hopper\help;$HELPPATH
|
||||
set KEYBOARD=us_colemak
|
||||
@@ -1,3 +1,3 @@
|
||||
TVDOS (c) 2020-2024 CuriousTorvald
|
||||
TVDOS (c) 2020-2026 CuriousTorvald
|
||||
|
||||
TVDOS is provided "as is", without warranty of any kind; in no event shall the authors or copyright holders be liable for any claim, damages or other liabilities. Run 'less COPYING' for more information.
|
||||
@@ -1,24 +1,181 @@
|
||||
graphics.setBackground(2,1,3);
|
||||
graphics.resetPalette();
|
||||
graphics.setBackground(2,1,3)
|
||||
graphics.resetPalette()
|
||||
const GL = require("gl")
|
||||
const win = require("wintex")
|
||||
const keysym = require("keysym")
|
||||
|
||||
function captureUserInput() {
|
||||
sys.poke(-40, 1);
|
||||
sys.poke(-40, 1)
|
||||
}
|
||||
|
||||
function getKeyPushed(keyOrder) {
|
||||
return sys.peek(-41 - keyOrder);
|
||||
return sys.peek(-41 - keyOrder)
|
||||
}
|
||||
|
||||
let _fsh = {};
|
||||
_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w=="));
|
||||
_fsh.scrdim = con.getmaxyx();
|
||||
_fsh.scrwidth = _fsh.scrdim[1];
|
||||
_fsh.scrheight = _fsh.scrdim[0];
|
||||
_fsh.brandName = "f\xb3Sh";
|
||||
function readMousePos() {
|
||||
let lx = sys.peek(-33) & 0xFF
|
||||
let hx = sys.peek(-34) & 0xFF
|
||||
let ly = sys.peek(-35) & 0xFF
|
||||
let hy = sys.peek(-36) & 0xFF
|
||||
return [(hx << 8) | lx, (hy << 8) | ly]
|
||||
}
|
||||
|
||||
function readMouseButtons() {
|
||||
return sys.peek(-37) & 0xFF
|
||||
}
|
||||
|
||||
// Returns true if any of the eight key event buffer slots holds keycode `kc`.
|
||||
function isKeyDown(kc) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if ((sys.peek(-41 - i) & 0xFF) === kc) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let _fsh = {}
|
||||
|
||||
// Config file path
|
||||
_fsh.CONFIG_PATH = "A:/home/config/fshrc"
|
||||
|
||||
// Widget row caps (must match the loop bounds in draw())
|
||||
_fsh.TODO_MAX_ROWS = 13 // todoWidget draws i = 0..12
|
||||
_fsh.QA_MAX_ROWS = 22 // quickAccessWidget draws i = 0..21
|
||||
_fsh.TODO_TEXT_WIDTH = 24 // visible characters per todo row
|
||||
_fsh.QA_LABEL_WIDTH = 24 // visible characters per QA label
|
||||
_fsh.QA_CMD_WIDTH = 60 // command path field width in dialog
|
||||
|
||||
// Highlight foreground for keyboard focus on widget lists. The background
|
||||
// stays transparent (255) so the wallpaper continues to show through.
|
||||
_fsh.HL_FG = 230
|
||||
_fsh.HL_BG = 255
|
||||
|
||||
// Default Quick Access entries when fshrc is missing or empty
|
||||
_fsh.DEFAULT_QA = [
|
||||
["Files", "/tvdos/bin/zsh.js"],
|
||||
["Editor", "/tvdos/bin/edit.js"],
|
||||
["BASIC", "/tbas/basic.js"],
|
||||
["DOS Shell", "/tvdos/bin/command.js /fancy"]
|
||||
]
|
||||
|
||||
// Mouse button bits (MMIO[36] layout per IOSpace.kt)
|
||||
_fsh.MB_LEFT = 1
|
||||
_fsh.MB_RIGHT = 2
|
||||
|
||||
// Current focus: null or {widgetId: string, index: number}.
|
||||
// Index uses the same convention as hitTest: 0..length-1 are entries,
|
||||
// `length` is the "+ Click to add" row.
|
||||
_fsh.focus = null
|
||||
|
||||
// Parse fshrc text into {todos: [[text, done], ...], qa: [[label, cmd], ...]}.
|
||||
// Returns null for both arrays when input is empty/whitespace.
|
||||
_fsh.parseConfig = function(text) {
|
||||
let todos = []
|
||||
let qa = []
|
||||
let section = null
|
||||
if (!text) return {todos: todos, qa: qa}
|
||||
let lines = text.split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]
|
||||
// strip trailing \r if any
|
||||
if (line.length && line.charCodeAt(line.length - 1) === 13) {
|
||||
line = line.substring(0, line.length - 1)
|
||||
}
|
||||
if (line.length === 0) continue
|
||||
if (line.charAt(0) === "[") {
|
||||
let close = line.indexOf("]")
|
||||
if (close > 0) {
|
||||
let name = line.substring(1, close).trim().toUpperCase()
|
||||
if (name === "TODO" || name === "QUICK_ACCESS") section = name
|
||||
else section = null // unknown section: ignore until next header
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (section === "TODO") {
|
||||
if (line.length < 2) continue
|
||||
let marker = line.charAt(0)
|
||||
if ((marker === "+" || marker === "-") && line.charAt(1) === " ") {
|
||||
todos.push([line.substring(2), marker === "+"])
|
||||
}
|
||||
} else if (section === "QUICK_ACCESS") {
|
||||
let comma = line.indexOf(",")
|
||||
if (comma <= 0) continue // need a non-empty label
|
||||
let label = line.substring(0, comma)
|
||||
let cmd = line.substring(comma + 1)
|
||||
qa.push([label, cmd])
|
||||
}
|
||||
}
|
||||
return {todos: todos, qa: qa}
|
||||
}
|
||||
|
||||
// Build fshrc text from in-memory model. Inverse of parseConfig.
|
||||
_fsh.serializeConfig = function(todos, qa) {
|
||||
let out = "[TODO]\n"
|
||||
for (let i = 0; i < todos.length; i++) {
|
||||
let t = todos[i]
|
||||
out += (t[1] ? "+ " : "- ") + t[0] + "\n"
|
||||
}
|
||||
out += "\n[QUICK_ACCESS]\n"
|
||||
for (let i = 0; i < qa.length; i++) {
|
||||
out += qa[i][0] + "," + qa[i][1] + "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Read fshrc; populate todoWidget.todoList and quickAccessWidget.entries.
|
||||
// Falls back to defaults on missing/empty/malformed file.
|
||||
_fsh.loadConfig = function() {
|
||||
let f = files.open(_fsh.CONFIG_PATH)
|
||||
let parsed = {todos: [], qa: []}
|
||||
if (f.exists) {
|
||||
try {
|
||||
parsed = _fsh.parseConfig(f.sread())
|
||||
} catch (e) {
|
||||
serial.printerr("fsh.loadConfig: parse failed: " + e)
|
||||
parsed = {todos: [], qa: []}
|
||||
}
|
||||
}
|
||||
todoWidget.todoList = parsed.todos
|
||||
quickAccessWidget.entries = (parsed.qa.length > 0)
|
||||
? parsed.qa
|
||||
: _fsh.DEFAULT_QA.slice() // copy so saves don't mutate the constant
|
||||
}
|
||||
|
||||
// Persist the current in-memory todos + QA entries to fshrc.
|
||||
_fsh.saveConfig = function() {
|
||||
try {
|
||||
let f = files.open(_fsh.CONFIG_PATH)
|
||||
if (!f.exists) f.mkFile()
|
||||
f.swrite(_fsh.serializeConfig(todoWidget.todoList, quickAccessWidget.entries))
|
||||
} catch (e) {
|
||||
serial.printerr("fsh.saveConfig: write failed: " + e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Map (mouse char x, mouse char y) to a row index for a widget drawn at
|
||||
// (xoff, yoff) with `length` existing entries and `maxRows` total rows.
|
||||
// Returns null / {kind:"add"} / {kind:"item", index: i}.
|
||||
_fsh.hitTestList = function(charX, charY, xoff, yoff, textWidth, length, maxRows) {
|
||||
// Each row sits at (yoff + i + 2, xoff..xoff + textWidth + 1).
|
||||
// Column range: icon at xoff, text at xoff+2 .. xoff+1+textWidth.
|
||||
// Allow clicks anywhere on the row's char cells (icon + text region).
|
||||
let relY = charY - yoff - 2
|
||||
if (relY < 0 || relY >= maxRows) return null
|
||||
if (charX < xoff || charX > xoff + 1 + textWidth) return null
|
||||
if (relY < length) return {kind: "item", index: relY}
|
||||
if (relY === length) return {kind: "add"}
|
||||
return null
|
||||
}
|
||||
|
||||
_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w=="))
|
||||
_fsh.scrdim = con.getmaxyx()
|
||||
_fsh.scrwidth = _fsh.scrdim[1]
|
||||
_fsh.scrheight = _fsh.scrdim[0]
|
||||
_fsh.brandName = "f\xb3Sh"
|
||||
_fsh.brandLogoTexSmall = new GL.Texture(24, 14, gzip.decomp(base64.atob(
|
||||
"H4sIAAAAAAAAAPv/Hy/4Qbz458+fIeILQQBIwoSh6qECuMVBukCmIJkDVQ+RQNgLE0MX/w+1lyhxqIUwTLJ/sQMAcIXsbVABAAA="
|
||||
)));
|
||||
_fsh.scrlayout = ["com.fsh.clock","com.fsh.calendar","com.fsh.todo_list", "com.fsh.quick_access"];
|
||||
)))
|
||||
_fsh.scrlayout = ["com.fsh.clock","com.fsh.calendar","com.fsh.todo_list", "com.fsh.quick_access"]
|
||||
|
||||
_fsh.drawWallpaper = function() {
|
||||
let wp = files.open("A:/home/wall.bytes")
|
||||
@@ -28,85 +185,85 @@ _fsh.drawWallpaper = function() {
|
||||
wp.pread(b, 250880, 0)
|
||||
dma.ramToFrame(b, 0, 250880)
|
||||
sys.free(b)
|
||||
};
|
||||
}
|
||||
|
||||
_fsh.drawTitlebar = function(titletext) {
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 0, 0, 560, 14);
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 0, 0, 560, 14)
|
||||
if (titletext === undefined || titletext.length == 0) {
|
||||
con.move(1,1);
|
||||
print(" ".repeat(_fsh.scrwidth));
|
||||
GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0);
|
||||
con.move(1,1)
|
||||
print(" ".repeat(_fsh.scrwidth))
|
||||
GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0)
|
||||
}
|
||||
else {
|
||||
con.color_pair(240, 255);
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14);
|
||||
con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2);
|
||||
print(titletext);
|
||||
con.color_pair(240, 255)
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14)
|
||||
con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2)
|
||||
print(titletext)
|
||||
}
|
||||
con.color_pair(254, 255);
|
||||
};
|
||||
con.color_pair(254, 255)
|
||||
}
|
||||
|
||||
|
||||
_fsh.Widget = function(id, w, h) {
|
||||
this.identifier = id;
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
this.identifier = id
|
||||
this.width = w
|
||||
this.height = h
|
||||
|
||||
if (!this.identifier) {
|
||||
this.identifier = "";
|
||||
this.identifier = ""
|
||||
}
|
||||
|
||||
//this.update = function() {};
|
||||
//this.update = function() {}
|
||||
/**
|
||||
* Params charXoff and charYoff are ZERO-BASED!
|
||||
*/
|
||||
this.draw = function(charXoff, charYoff) {};
|
||||
this.draw = function(charXoff, charYoff) {}
|
||||
}
|
||||
|
||||
_fsh.widgets = {}
|
||||
_fsh.registerNewWidget = function(widget) {
|
||||
_fsh.widgets[widget.identifier] = widget;
|
||||
_fsh.widgets[widget.identifier] = widget
|
||||
}
|
||||
|
||||
let clockWidget = new _fsh.Widget("com.fsh.clock", _fsh.scrwidth - 8, 7*2);
|
||||
let clockWidget = new _fsh.Widget("com.fsh.clock", _fsh.scrwidth - 8, 7*2)
|
||||
clockWidget.numberSheet = new GL.SpriteSheet(19, 22, new GL.Texture(190, 22, gzip.decomp(base64.atob(
|
||||
"H4sIAAAAAAAAAMWVW3LEMAgE739aHcFJJV5ZMD2I9ToVfcl4GBr80HF8r/FaR1ozMuIyoUu87lEXI0al5qVR5AebSwchSaNE6Nyo1Nw5HXF3SfPT4Bshl"+
|
||||
"EycA8RD96mLlHbuhTgOrfLnUDZspafbSQWk56WEGvQEtWaWwgb8iz7a8AOXhsraO/q9Qw2/GnXovfVN+q2wM/p/oddn2cjF239GX3y11+SWCtc6FTHC1v"+
|
||||
"TVPkDPWWn0w+DDz93UX9v9mF5KIsQ6OdN2KJoB4ui1bXXr0AMp0YfiQo//4XhpK8555dsNehAqVS5uhb5iHn3Kko769J59KmLBe/TSR7hcsd+hr+HnrwR"+
|
||||
"9uvRF9+D3MP14gN7lqx+8OuNT+uqt3NFX3SN9fTbeeHNq+C29pRWzX5+Rcm7SZyjOKJ/2hkSPqul4xN279DrSYvCrNu2NI7ZMp1ouBxK3KBVVnEeAUWbK"+
|
||||
"MUDn5DPsPxmUqHZQjGpy2hergM3EVBAAAA=="
|
||||
))));
|
||||
))))
|
||||
|
||||
clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v"));
|
||||
clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"];
|
||||
clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "];
|
||||
clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v"))
|
||||
clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"]
|
||||
clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "]
|
||||
clockWidget.draw = function(charXoff, charYoff) {
|
||||
con.color_pair(254, 255);
|
||||
let xoff = charXoff * 7;
|
||||
let yoff = charYoff * 14 + 3;
|
||||
let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0);
|
||||
let mins = timeInMinutes % 60;
|
||||
let hours = ((timeInMinutes / 60)|0) % 24;
|
||||
let ordinalDay = ((timeInMinutes / (60*24))|0) % 120;
|
||||
let visualDay = (ordinalDay % 30) + 1;
|
||||
let months = ((timeInMinutes / (60*24*30))|0) % 4;
|
||||
let dayName = ordinalDay % 7; // 0 for Mondag
|
||||
if (ordinalDay == 119) dayName = 7; // Verddag
|
||||
let years = ((timeInMinutes / (60*24*30*120))|0) + 125;
|
||||
con.color_pair(254, 255)
|
||||
let xoff = charXoff * 7
|
||||
let yoff = charYoff * 14 + 3
|
||||
let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0)
|
||||
let mins = timeInMinutes % 60
|
||||
let hours = ((timeInMinutes / 60)|0) % 24
|
||||
let ordinalDay = ((timeInMinutes / (60*24))|0) % 120
|
||||
let visualDay = (ordinalDay % 30) + 1
|
||||
let months = ((timeInMinutes / (60*24*30))|0) % 4
|
||||
let dayName = ordinalDay % 7 // 0 for Mondag
|
||||
if (ordinalDay == 119) dayName = 7 // Verddag
|
||||
let years = ((timeInMinutes / (60*24*30*120))|0) + 125
|
||||
// draw timepiece
|
||||
GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, yoff, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, hours % 10, 0, xoff + 24, yoff, 1);
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 5, 1);
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, (mins / 10)|0, 0, xoff + 57, yoff, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, yoff, 1)
|
||||
GL.drawSprite(clockWidget.numberSheet, hours % 10, 0, xoff + 24, yoff, 1)
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 5, 1)
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 1)
|
||||
GL.drawSprite(clockWidget.numberSheet, (mins / 10)|0, 0, xoff + 57, yoff, 1)
|
||||
GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1)
|
||||
// print month and date
|
||||
con.move(1 + charYoff, 17 + charXoff);
|
||||
print(clockWidget.monthNames[months]+" "+visualDay);
|
||||
con.move(1 + charYoff, 17 + charXoff)
|
||||
print(clockWidget.monthNames[months]+" "+visualDay)
|
||||
// print year and dayname
|
||||
con.move(2 + charYoff, 17 + charXoff);
|
||||
print("\xE7"+years+" "+clockWidget.dayNames[dayName]);
|
||||
};
|
||||
con.move(2 + charYoff, 17 + charXoff)
|
||||
print("\xE7"+years+" "+clockWidget.dayNames[dayName])
|
||||
}
|
||||
|
||||
|
||||
let calendarWidget = new _fsh.Widget("com.fsh.calendar", (_fsh.scrwidth - 8) / 2, 7*6)
|
||||
@@ -171,70 +328,284 @@ calendarWidget.draw = function(charXoff, charYoff) {
|
||||
let todoWidget = new _fsh.Widget("com.fsh.todo_list", (_fsh.scrwidth - 8) / 2, 7*10)
|
||||
todoWidget.todoList = [["Hello, world!", true]]
|
||||
todoWidget.draw = function(charXoff, charYoff) {
|
||||
let focusIndex = (_fsh.focus && _fsh.focus.widgetId === todoWidget.identifier)
|
||||
? _fsh.focus.index : -1
|
||||
|
||||
con.color_pair(254, 255)
|
||||
let xoff = charXoff * 7
|
||||
let yoff = charYoff * 14 + 3
|
||||
|
||||
con.move(charYoff, charXoff)
|
||||
print("========== TODO ==========")
|
||||
print('\u00CD'.repeat(10)+" TODO "+'\u00CD'.repeat(10))
|
||||
|
||||
for (let i = 0; i <= 12; i++) {
|
||||
let list = todoWidget.todoList[i] || ["Click to add", null]
|
||||
let list = todoWidget.todoList[i] || ["Click to add"+" ".repeat(_fsh.TODO_TEXT_WIDTH - 12), null]
|
||||
let isFocused = (i === focusIndex)
|
||||
|
||||
if (list[1] === null) con.color_pair(249, 255)
|
||||
if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG)
|
||||
else if (list[1] === null) con.color_pair(249, 255)
|
||||
else con.color_pair(254, 255)
|
||||
|
||||
con.move(charYoff + i + 2, charXoff)
|
||||
con.addch((list[1] === null) ? 43 : (list[1]) ? 0x9F : 0x9E)
|
||||
|
||||
if (i > todoWidget.todoList.length) {
|
||||
// Filler row \u2014 keep underscores but don't highlight (can't focus here)
|
||||
con.color_pair(254, 255)
|
||||
for (let k = 0; k < 24; k++) {
|
||||
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
||||
}
|
||||
}
|
||||
else {
|
||||
con.move(charYoff + i + 2, charXoff + 2)
|
||||
print(`${list[0]}`)
|
||||
// Pad text to TODO_TEXT_WIDTH so the highlight bar covers full row
|
||||
let text = `${list[0]}`
|
||||
if (text.length > _fsh.TODO_TEXT_WIDTH) text = text.substring(0, _fsh.TODO_TEXT_WIDTH)
|
||||
if (isFocused) text = text + " ".repeat(_fsh.TODO_TEXT_WIDTH - text.length)
|
||||
print(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let quickAccessWidget = new _fsh.Widget("com.fsh.quick_access", (_fsh.scrwidth - 8) / 2, 7*20)
|
||||
quickAccessWidget.entries = [
|
||||
["Files", "/tvdos/bin/explorer.js"],
|
||||
quickAccessWidget.entries = [ // TODO read from /home/config/fshrc
|
||||
["Files", "/tvdos/bin/zfm.js"],
|
||||
["Editor", "/tvdos/bin/edit.js"],
|
||||
["BASIC", "/tbas/basic.js"],
|
||||
["DOS Shell", "/tvdos/bin/command.js /fancy"]
|
||||
["DOS Shell", "/tvdos/bin/command.js -fancy"]
|
||||
]
|
||||
quickAccessWidget.draw = function(charXoff, charYoff) {
|
||||
let focusIndex = (_fsh.focus && _fsh.focus.widgetId === quickAccessWidget.identifier)
|
||||
? _fsh.focus.index : -1
|
||||
|
||||
con.color_pair(254, 255)
|
||||
let xoff = charXoff * 7
|
||||
let yoff = charYoff * 14 + 3
|
||||
|
||||
con.move(charYoff, charXoff)
|
||||
print("====== QUICK ACCESS ======")
|
||||
print('\u00CD'.repeat(6)+" QUICK ACCESS "+'\u00CD'.repeat(6))
|
||||
|
||||
for (let i = 0; i <= 21; i++) {
|
||||
let list = quickAccessWidget.entries[i] || ["Click to add", null]
|
||||
let list = quickAccessWidget.entries[i] || ["Click to add"+" ".repeat(_fsh.QA_LABEL_WIDTH - 12), null]
|
||||
let isFocused = (i === focusIndex)
|
||||
|
||||
if (list[1] === null) con.color_pair(249, 255)
|
||||
if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG)
|
||||
else if (list[1] === null) con.color_pair(249, 255)
|
||||
else con.color_pair(254, 255)
|
||||
|
||||
con.move(charYoff + i + 2, charXoff)
|
||||
con.addch((list[1] === null) ? 0xF9 : (list[1]) ? 7 : 0x7F)
|
||||
|
||||
if (i > quickAccessWidget.entries.length) {
|
||||
con.color_pair(254, 255)
|
||||
for (let k = 0; k < 24; k++) {
|
||||
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
||||
}
|
||||
}
|
||||
else {
|
||||
con.move(charYoff + i + 2, charXoff + 2)
|
||||
print(`${list[0]}`)
|
||||
let text = `${list[0]}`
|
||||
if (text.length > _fsh.QA_LABEL_WIDTH) text = text.substring(0, _fsh.QA_LABEL_WIDTH)
|
||||
if (isFocused) text = text + " ".repeat(_fsh.QA_LABEL_WIDTH - text.length)
|
||||
print(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
todoWidget.hitTest = function(charX, charY, xoff, yoff) {
|
||||
return _fsh.hitTestList(charX, charY, xoff, yoff,
|
||||
_fsh.TODO_TEXT_WIDTH, todoWidget.todoList.length, _fsh.TODO_MAX_ROWS)
|
||||
}
|
||||
|
||||
quickAccessWidget.hitTest = function(charX, charY, xoff, yoff) {
|
||||
return _fsh.hitTestList(charX, charY, xoff, yoff,
|
||||
_fsh.QA_LABEL_WIDTH, quickAccessWidget.entries.length, _fsh.QA_MAX_ROWS)
|
||||
}
|
||||
|
||||
|
||||
// Re-render the whole shell. Use after a dialog closes (which clobbered
|
||||
// the underlying char cells) or after execApp returns.
|
||||
_fsh.redrawAll = function() {
|
||||
con.color_pair(254, 255)
|
||||
con.clear()
|
||||
graphics.clearPixels(255)
|
||||
graphics.clearPixels2(255)
|
||||
graphics.setFramebufferScroll(0, 0)
|
||||
_fsh.drawWallpaper()
|
||||
_fsh.drawTitlebar()
|
||||
_fsh.widgets["com.fsh.clock"].draw(25, 3)
|
||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8)
|
||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17)
|
||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8)
|
||||
}
|
||||
|
||||
_fsh.openAddTodoDialog = function() {
|
||||
let res = win.showDialog({
|
||||
title: "New Todo",
|
||||
fields: [{label: "Text:", initial: "", width: _fsh.TODO_TEXT_WIDTH}],
|
||||
allowDelete: false
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action !== "ok") return
|
||||
let text = res.values[0].trim()
|
||||
if (text.length === 0) return
|
||||
if (todoWidget.todoList.length >= _fsh.TODO_MAX_ROWS) return
|
||||
todoWidget.todoList.push([text, false])
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.openEditTodoDialog = function(index) {
|
||||
let entry = todoWidget.todoList[index]
|
||||
if (!entry) return
|
||||
let res = win.showDialog({
|
||||
title: "Edit Todo",
|
||||
fields: [{label: "Text:", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}],
|
||||
allowDelete: true
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action === "cancel") return
|
||||
if (res.action === "delete") {
|
||||
todoWidget.todoList.splice(index, 1)
|
||||
_fsh.saveConfig()
|
||||
return
|
||||
}
|
||||
let text = res.values[0].trim()
|
||||
if (text.length === 0) return
|
||||
todoWidget.todoList[index] = [text, entry[1]]
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.openAddQaDialog = function() {
|
||||
let res = win.showDialog({
|
||||
title: "New Quick Access",
|
||||
fields: [
|
||||
{label: "Label:", initial: "", width: _fsh.QA_LABEL_WIDTH},
|
||||
{label: "Command:", initial: "", width: _fsh.QA_CMD_WIDTH}
|
||||
],
|
||||
allowDelete: false
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action !== "ok") return
|
||||
let label = res.values[0].trim()
|
||||
let cmd = res.values[1].trim()
|
||||
if (label.length === 0 || cmd.length === 0) return
|
||||
if (quickAccessWidget.entries.length >= _fsh.QA_MAX_ROWS) return
|
||||
quickAccessWidget.entries.push([label, cmd])
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.openEditQaDialog = function(index) {
|
||||
let entry = quickAccessWidget.entries[index]
|
||||
if (!entry) return
|
||||
let res = win.showDialog({
|
||||
title: "Edit Quick Access",
|
||||
fields: [
|
||||
{label: "Label:", initial: entry[0], width: _fsh.QA_LABEL_WIDTH},
|
||||
{label: "Command:", initial: entry[1], width: _fsh.QA_CMD_WIDTH}
|
||||
],
|
||||
allowDelete: true
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action === "cancel") return
|
||||
if (res.action === "delete") {
|
||||
quickAccessWidget.entries.splice(index, 1)
|
||||
_fsh.saveConfig()
|
||||
return
|
||||
}
|
||||
let label = res.values[0].trim()
|
||||
let cmd = res.values[1].trim()
|
||||
if (label.length === 0 || cmd.length === 0) return
|
||||
quickAccessWidget.entries[index] = [label, cmd]
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.toggleTodoDone = function(index) {
|
||||
let entry = todoWidget.todoList[index]
|
||||
if (!entry) return
|
||||
entry[1] = !entry[1]
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
// Launch a Quick Access entry. cmd is the verbatim string the user typed.
|
||||
// We split on first space to derive a program path + args; if the path
|
||||
// has no leading "/", we treat it as relative to the current drive.
|
||||
_fsh.launchEntry = function(label, cmd) {
|
||||
let firstSpace = cmd.indexOf(" ")
|
||||
let progPath = (firstSpace >= 0) ? cmd.substring(0, firstSpace) : cmd
|
||||
let argTail = (firstSpace >= 0) ? cmd.substring(firstSpace + 1) : ""
|
||||
let fullPath = progPath.startsWith("/") ? ("A:" + progPath) : progPath
|
||||
|
||||
try {
|
||||
let f = files.open(fullPath)
|
||||
if (!f.exists) {
|
||||
serial.printerr("fsh.launchEntry: not found: " + fullPath)
|
||||
return
|
||||
}
|
||||
let code = f.sread()
|
||||
let tokens = [progPath].concat(argTail.length ? argTail.split(" ") : [])
|
||||
|
||||
// erase all pixels and draw wallpaper
|
||||
con.reset_graphics()
|
||||
con.clear()
|
||||
graphics.clearPixels(255)
|
||||
graphics.clearPixels2(255)
|
||||
_fsh.drawWallpaper()
|
||||
con.curs_set(1)
|
||||
|
||||
execApp(code, tokens)
|
||||
} catch (e) {
|
||||
serial.printerr("fsh.launchEntry: " + label + " failed: " + e)
|
||||
}
|
||||
con.curs_set(0)
|
||||
graphics.setBackground(2,1,3)
|
||||
graphics.resetPalette()
|
||||
// Apps (e.g. zfm) may switch to graphics mode 0; restore mode 3 so the
|
||||
// clock widget on framebuffer 2 is composited again.
|
||||
graphics.setGraphicsMode(3)
|
||||
_fsh.redrawAll()
|
||||
}
|
||||
|
||||
// Layout map: widget positions hard-coded to match the draw calls below.
|
||||
_fsh.layouts = {
|
||||
"com.fsh.todo_list": {xoff: 10, yoff: 17, widget: null},
|
||||
"com.fsh.quick_access": {xoff: 47, yoff: 8, widget: null}
|
||||
}
|
||||
|
||||
// Find which widget (if any) was hit by (charX, charY). Returns
|
||||
// {widgetId, hit} or null.
|
||||
_fsh.findHit = function(charX, charY) {
|
||||
let ids = ["com.fsh.todo_list", "com.fsh.quick_access"]
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
let id = ids[i]
|
||||
let layout = _fsh.layouts[id]
|
||||
let widget = _fsh.widgets[id]
|
||||
let hit = widget.hitTest(charX, charY, layout.xoff, layout.yoff)
|
||||
if (hit) return {widgetId: id, hit: hit}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
_fsh.dispatchLeft = function(widgetId, hit) {
|
||||
if (hit.kind === "add") {
|
||||
if (widgetId === "com.fsh.todo_list") _fsh.openAddTodoDialog()
|
||||
else _fsh.openAddQaDialog()
|
||||
return
|
||||
}
|
||||
// hit.kind === "item"
|
||||
if (widgetId === "com.fsh.todo_list") {
|
||||
_fsh.toggleTodoDone(hit.index)
|
||||
} else {
|
||||
let entry = quickAccessWidget.entries[hit.index]
|
||||
if (entry) _fsh.launchEntry(entry[0], entry[1])
|
||||
}
|
||||
}
|
||||
|
||||
_fsh.dispatchRight = function(widgetId, hit) {
|
||||
if (hit.kind !== "item") return
|
||||
if (widgetId === "com.fsh.todo_list") _fsh.openEditTodoDialog(hit.index)
|
||||
else _fsh.openEditQaDialog(hit.index)
|
||||
}
|
||||
|
||||
|
||||
// change graphics mode and check if it's supported
|
||||
graphics.setGraphicsMode(3)
|
||||
@@ -260,29 +631,130 @@ _fsh.drawWallpaper()
|
||||
_fsh.drawTitlebar()
|
||||
|
||||
|
||||
// TEST
|
||||
con.move(2,1);
|
||||
print("fSh is very much in-dev! Hit backspace to exit")
|
||||
// Load persisted state before the first draw
|
||||
_fsh.loadConfig();
|
||||
|
||||
// keyEventBuffers (read via sys.peek(-41-i)) holds *raw libGDX keycodes*,
|
||||
// not the cooked TSVM scancodes that con.getch() returns. Existing fsh.js
|
||||
// already uses 67 for Backspace (libGDX DEL); follow the same scheme here.
|
||||
const KEY_ESC = keysym.ESCAPE
|
||||
const KEY_ENTER = keysym.ENTER
|
||||
const KEY_UP = keysym.UP
|
||||
const KEY_DOWN = keysym.DOWN
|
||||
const KEY_LEFT = keysym.LEFT
|
||||
const KEY_RIGHT = keysym.RIGHT
|
||||
const KEY_LSHIFT = keysym.SHIFT_LEFT
|
||||
const KEY_RSHIFT = keysym.SHIFT_RIGHT
|
||||
|
||||
let prevButtons = 0
|
||||
let prevMouseCharX = -1
|
||||
let prevMouseCharY = -1
|
||||
let keyLatch = {} // {keycode: true} while the key is held — debounces "just pressed"
|
||||
|
||||
// TODO update for events: key down (updates some widgets), timer (updates clock and calendar widgets)
|
||||
while (true) {
|
||||
captureUserInput();
|
||||
if (getKeyPushed(0) == 67) break;
|
||||
captureUserInput()
|
||||
|
||||
_fsh.widgets["com.fsh.clock"].draw(25, 3);
|
||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8);
|
||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17);
|
||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8);
|
||||
// -- keyboard --
|
||||
if (isKeyDown(KEY_ESC)) break;
|
||||
|
||||
sys.spin();sys.spin()
|
||||
let shiftDown = isKeyDown(KEY_LSHIFT) || isKeyDown(KEY_RSHIFT)
|
||||
let enterPressed = false
|
||||
|
||||
// Edge-detect each navigation key
|
||||
function edge(kc) {
|
||||
let down = isKeyDown(kc)
|
||||
let was = !!keyLatch[kc]
|
||||
keyLatch[kc] = down
|
||||
return down && !was
|
||||
}
|
||||
|
||||
if (edge(KEY_ENTER)) enterPressed = true;
|
||||
let navUp = edge(KEY_UP)
|
||||
let navDown = edge(KEY_DOWN)
|
||||
let navLeft = edge(KEY_LEFT)
|
||||
let navRight = edge(KEY_RIGHT)
|
||||
|
||||
// -- mouse --
|
||||
// MMIO returns VM-screen pixel coords (origin at the top-left of the framebuffer).
|
||||
// Widget xoff/yoff are passed straight into con.move(y, x), which is 1-indexed, so
|
||||
// we offset by +1 here. Without this the click registers one cell up-and-left from
|
||||
// where the user's pointer is, because pixel 0 = con.move(1, 1).
|
||||
let pos = readMousePos()
|
||||
let charX = (pos[0] / 7 | 0) + 1
|
||||
let charY = (pos[1] / 14 | 0) + 1
|
||||
let mouseMoved = (charX !== prevMouseCharX || charY !== prevMouseCharY)
|
||||
prevMouseCharX = charX
|
||||
prevMouseCharY = charY
|
||||
|
||||
let buttons = readMouseButtons()
|
||||
let leftEdge = ((buttons & _fsh.MB_LEFT) !== 0) && ((prevButtons & _fsh.MB_LEFT) === 0)
|
||||
let rightEdge = ((buttons & _fsh.MB_RIGHT) !== 0) && ((prevButtons & _fsh.MB_RIGHT) === 0)
|
||||
prevButtons = buttons
|
||||
|
||||
// -- focus update --
|
||||
if (navUp || navDown || navLeft || navRight) {
|
||||
if (!_fsh.focus) _fsh.focus = {widgetId: "com.fsh.todo_list", index: 0}
|
||||
if (navUp || navDown) {
|
||||
let layout = _fsh.layouts[_fsh.focus.widgetId]
|
||||
let maxRows = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS
|
||||
let length = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||
let maxIdx = Math.min(length, maxRows - 1)
|
||||
let next = _fsh.focus.index + (navDown ? 1 : -1)
|
||||
if (next < 0) next = 0
|
||||
if (next > maxIdx) next = maxIdx
|
||||
_fsh.focus.index = next
|
||||
} else {
|
||||
// Left/right switches widget
|
||||
let other = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? "com.fsh.quick_access" : "com.fsh.todo_list"
|
||||
let otherLength = (other === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||
let otherMaxRows = (other === "com.fsh.todo_list")
|
||||
? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS
|
||||
let otherMaxIdx = Math.min(otherLength, otherMaxRows - 1)
|
||||
_fsh.focus = {widgetId: other, index: Math.min(_fsh.focus.index, otherMaxIdx)}
|
||||
}
|
||||
} else if (mouseMoved) {
|
||||
let h = _fsh.findHit(charX, charY)
|
||||
_fsh.focus = h ? {widgetId: h.widgetId, index: h.hit.kind === "add"
|
||||
? ((h.widgetId === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length
|
||||
: quickAccessWidget.entries.length)
|
||||
: h.hit.index} : null
|
||||
}
|
||||
|
||||
// -- mouse click dispatch --
|
||||
if (leftEdge) {
|
||||
let h = _fsh.findHit(charX, charY)
|
||||
if (h) _fsh.dispatchLeft(h.widgetId, h.hit)
|
||||
} else if (rightEdge) {
|
||||
let h = _fsh.findHit(charX, charY)
|
||||
if (h) _fsh.dispatchRight(h.widgetId, h.hit)
|
||||
}
|
||||
|
||||
// -- keyboard dispatch (synthesise click at focus) --
|
||||
if (enterPressed && _fsh.focus) {
|
||||
let length = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||
let hit = (_fsh.focus.index < length)
|
||||
? {kind: "item", index: _fsh.focus.index}
|
||||
: (_fsh.focus.index === length ? {kind: "add"} : null)
|
||||
if (hit) {
|
||||
if (shiftDown) _fsh.dispatchRight(_fsh.focus.widgetId, hit)
|
||||
else _fsh.dispatchLeft(_fsh.focus.widgetId, hit)
|
||||
}
|
||||
}
|
||||
|
||||
// -- redraw --
|
||||
_fsh.widgets["com.fsh.clock"].draw(25, 3)
|
||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8)
|
||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17)
|
||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8)
|
||||
|
||||
sys.spin(); sys.spin()
|
||||
}
|
||||
|
||||
con.move(3,1);
|
||||
con.color_pair(201,255);
|
||||
print("cya!");
|
||||
|
||||
let konsht = 3412341241;
|
||||
println(konsht);
|
||||
|
||||
let pppp = graphics.getCursorYX();
|
||||
println(pppp.toString());
|
||||
con.reset_graphics()
|
||||
con.clear()
|
||||
@@ -1,11 +1,13 @@
|
||||
let url="http:localhost/testnet/test.txt"
|
||||
/*let url="https:raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/aa.hop.per"
|
||||
|
||||
let file = files.open("B:\\"+url)
|
||||
|
||||
if (!file.exists) {
|
||||
printerrln("No such URL: "+url)
|
||||
return 1
|
||||
}
|
||||
}*/
|
||||
|
||||
let text = file.sread()
|
||||
let net = require("A:/tvdos/include/net.mjs")
|
||||
let text = net.fetchText("https://raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/aa.hop.per")
|
||||
if (text === null) { printerrln("No such URL"); return 1 }
|
||||
println(text)
|
||||
|
||||
@@ -32,7 +32,7 @@ if (exec_args !== undefined && exec_args[1] !== undefined && exec_args[1].starts
|
||||
return 0
|
||||
}
|
||||
|
||||
const THEVERSION = "1.2.1"
|
||||
const THEVERSION = "1.2.2"
|
||||
|
||||
const PROD = true
|
||||
let INDEX_BASE = 0
|
||||
@@ -4197,7 +4197,7 @@ bF.load = function(args) { // LOAD function
|
||||
if (args[1] === undefined) throw lang.missingOperand
|
||||
var fileOpened = fs.open(args[1], "R")
|
||||
|
||||
|
||||
serial.printerr('load '+args[1])
|
||||
if (replUsrConfirmed || cmdbuf.length == 0) {
|
||||
if (!fileOpened) {
|
||||
fileOpened = fs.open(args[1]+".BAS", "R")
|
||||
@@ -4241,7 +4241,7 @@ bF.yes = function() {
|
||||
}
|
||||
}
|
||||
bF.catalog = function(args) { // CATALOG function
|
||||
if (args[1] === undefined) args[1] = "\\"
|
||||
if (args[1] === undefined) args[1] = BASIC_HOME_PATH
|
||||
var pathOpened = fs.open(args[1], 'R')
|
||||
if (!pathOpened) {
|
||||
throw lang.noSuchFile
|
||||
@@ -4251,6 +4251,57 @@ bF.catalog = function(args) { // CATALOG function
|
||||
com.sendMessage(port, "LIST")
|
||||
println(com.pullMessage(port))
|
||||
}
|
||||
// Load a file by absolute disk path (bypasses BASIC_HOME_PATH).
|
||||
// Used by COMPILE to fetch /tbas/compile.js.
|
||||
bF._slurpAbsolute = function(path) {
|
||||
var port = _BIOS.FIRST_BOOTABLE_PORT
|
||||
com.sendMessage(port[0], "FLUSH")
|
||||
com.sendMessage(port[0], "CLOSE")
|
||||
com.sendMessage(port[0], 'OPENR"' + path + '",' + port[1])
|
||||
if (com.getStatusCode(port[0]) != 0) return undefined
|
||||
com.sendMessage(port[0], "READ")
|
||||
if (com.getStatusCode(port[0]) >= 128) return undefined
|
||||
var s = com.pullMessage(port[0])
|
||||
com.sendMessage(port[0], "FLUSH"); com.sendMessage(port[0], "CLOSE")
|
||||
return s
|
||||
}
|
||||
bF.compile = function(args) { // COMPILE "OUT.JS" -- transpile cmdbuf to JS
|
||||
if (args[1] === undefined) {
|
||||
println("Usage: COMPILE \"out.js\""); return
|
||||
}
|
||||
if (cmdbuf.length === 0) {
|
||||
println("No program loaded"); return
|
||||
}
|
||||
if (bS._compileImpl === undefined) {
|
||||
// Lazy-load compile.js from /tbas/compile.js
|
||||
var src = bF._slurpAbsolute("/tbas/compile.js")
|
||||
if (src === undefined) {
|
||||
println("Cannot load /tbas/compile.js")
|
||||
return
|
||||
}
|
||||
try { eval(src) } catch (e) {
|
||||
println("Failed to load compiler: " + e); return
|
||||
}
|
||||
if (bS._compileImpl === undefined) {
|
||||
println("compile.js loaded but did not define bS._compileImpl"); return
|
||||
}
|
||||
}
|
||||
var outpath = args[1]
|
||||
// Strip surrounding quotes if any
|
||||
if ((outpath.charAt(0) === '"' || outpath.charAt(0) === "'") &&
|
||||
outpath.charAt(outpath.length - 1) === outpath.charAt(0)) {
|
||||
outpath = outpath.substring(1, outpath.length - 1)
|
||||
}
|
||||
// Default to .js extension if missing
|
||||
if (!/\.[A-Za-z0-9]+$/.test(outpath)) outpath += ".js"
|
||||
try {
|
||||
var n = bS._compileImpl(outpath)
|
||||
println("Wrote " + n + " bytes to " + outpath)
|
||||
} catch (e) {
|
||||
serial.printerr(e + "\n" + (e.stack || ""))
|
||||
println("Compile error: " + e)
|
||||
}
|
||||
}
|
||||
Object.freeze(bF)
|
||||
|
||||
if (exec_args !== undefined && exec_args[1] !== undefined) {
|
||||
|
||||
564
assets/disk0/tbas/compile.js
Normal file
564
assets/disk0/tbas/compile.js
Normal file
@@ -0,0 +1,564 @@
|
||||
// Terran BASIC -> JavaScript compiler
|
||||
// Loaded into basic.js's context by `bF.compile`. Re-uses bF._interpretLine
|
||||
// (tokeniser + elaborator + parser + pruner) verbatim and emits a self-
|
||||
// contained JS program that does its work via `let bS = require("tbas")`.
|
||||
//
|
||||
// On load, attaches `bS._compileImpl` to the live bS object.
|
||||
|
||||
;(function() {
|
||||
|
||||
// ---------- helpers ----------------------------------------------------------
|
||||
|
||||
function isValidJsId(s) {
|
||||
return /^[A-Z_][A-Z0-9_]*$/i.test(s)
|
||||
}
|
||||
function varRef(name) {
|
||||
const u = String(name).toUpperCase()
|
||||
return isValidJsId(u) ? `bS.__state.vars.${u}` : `bS.__state.vars[${JSON.stringify(u)}]`
|
||||
}
|
||||
function jsLit(v) { return JSON.stringify(v) }
|
||||
|
||||
// Resolve a literal AST node down to a raw JS value at compile time. Used
|
||||
// for harvesting DATA constants. Only constant-propagatable types are
|
||||
// permitted; otherwise compile-time evaluation fails.
|
||||
function literalValue(node) {
|
||||
if (!node) return undefined
|
||||
switch (node.astType) {
|
||||
case "num": return Number(node.astValue)
|
||||
case "string": return String(node.astValue)
|
||||
case "bool": return Boolean(node.astValue)
|
||||
case "null": return undefined
|
||||
case "lit": return String(node.astValue) // bare identifier in DATA: keep as string
|
||||
default:
|
||||
throw Error("DATA: unsupported literal node type: " + node.astType)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the maximum varIndex used at the immediate scope of a lambda body,
|
||||
// hence its arity.
|
||||
function lambdaArity(body) {
|
||||
let maxIdx = -1
|
||||
function walk(t, level) {
|
||||
if (!t || !t.astType) return
|
||||
if (t.astType === "defun_args" && t.astValue[0] === level) {
|
||||
if (t.astValue[1] > maxIdx) maxIdx = t.astValue[1]
|
||||
}
|
||||
// descend into nested usrdefun (its body lives in astValue, not leaves)
|
||||
if (t.astType === "usrdefun" && t.astValue && t.astValue.astLeaves !== undefined) {
|
||||
walk(t.astValue, level + 1)
|
||||
}
|
||||
// generic descent
|
||||
if (t.astLeaves) {
|
||||
for (let i = 0; i < t.astLeaves.length; i++) walk(t.astLeaves[i], level)
|
||||
}
|
||||
}
|
||||
walk(body, 0)
|
||||
return maxIdx + 1
|
||||
}
|
||||
|
||||
// ---------- expression lowering ---------------------------------------------
|
||||
|
||||
// `depth` tracks the number of enclosing lambdas during emission. When we
|
||||
// emit a lambda we increment it; defun_args [d, i] becomes _aN_i where
|
||||
// N = depth - 1 - d (the absolute lambda index of the binding scope).
|
||||
function compileExpr(tree, depth) {
|
||||
if (tree === undefined || tree === null) return "undefined"
|
||||
|
||||
// Empty parens / wrapper node: descend into the single child
|
||||
if (tree.astType === "null") {
|
||||
if (tree.astLeaves && tree.astLeaves[0] !== undefined) return compileExpr(tree.astLeaves[0], depth)
|
||||
return "undefined"
|
||||
}
|
||||
if (tree.astValue === undefined && tree.astLeaves && tree.astLeaves.length === 1) {
|
||||
return compileExpr(tree.astLeaves[0], depth)
|
||||
}
|
||||
|
||||
switch (tree.astType) {
|
||||
case "num": return String(Number(tree.astValue))
|
||||
case "string": return jsLit(String(tree.astValue))
|
||||
case "bool": return tree.astValue ? "true" : "false"
|
||||
case "lit": return compileLit(tree)
|
||||
case "defun_args": {
|
||||
const d = tree.astValue[0], i = tree.astValue[1]
|
||||
const scope = depth - 1 - d
|
||||
if (scope < 0) throw Error("defun_args refers to a scope outside the program (depth=" + depth + ", d=" + d + ")")
|
||||
return "_a" + scope + "_" + i
|
||||
}
|
||||
case "usrdefun": return compileLambdaExpr(tree, depth)
|
||||
case "array": return compileArrayRef(tree, depth)
|
||||
case "function": return compileFunctionExpr(tree, depth)
|
||||
case "op": return compileOpExpr(tree, depth)
|
||||
default:
|
||||
throw Error("Cannot compile expression node of type: " + tree.astType + " (value=" + tree.astValue + ")")
|
||||
}
|
||||
}
|
||||
|
||||
function compileLit(tree) {
|
||||
const name = String(tree.astValue).toUpperCase()
|
||||
// Built-in zero-arg / pass-as-value functions: when a builtin name is
|
||||
// referenced as a value (e.g. assigned to a variable for later use as a
|
||||
// higher-order arg), emit a JS function reference. For a plain variable
|
||||
// read, emit the vars table lookup.
|
||||
// Heuristic: if the name matches a builtin we know about, prefer the
|
||||
// function; otherwise, vars lookup.
|
||||
if (RUNTIME_BUILTINS.has(name)) {
|
||||
return "bS." + (isValidJsId(name) ? name : `[${jsLit(name)}]`)
|
||||
}
|
||||
return varRef(name)
|
||||
}
|
||||
|
||||
function compileArrayRef(tree, depth) {
|
||||
// tree.astValue = array variable name; tree.astLeaves = index expressions
|
||||
if (!tree.astLeaves || tree.astLeaves.length === 0) {
|
||||
return varRef(tree.astValue)
|
||||
}
|
||||
const indices = tree.astLeaves.map(l => compileExpr(l, depth))
|
||||
return `bS.__arrGet(${varRef(tree.astValue)}, [${indices.join(",")}])`
|
||||
}
|
||||
|
||||
function compileFunctionExpr(tree, depth) {
|
||||
const name = String(tree.astValue).toUpperCase()
|
||||
|
||||
if (name === "PRINT" || name === "EMIT") {
|
||||
// PRINT/EMIT used as expression — emit as IIFE returning undefined
|
||||
return "(" + compilePrintLike(tree, name, depth) + ", undefined)"
|
||||
}
|
||||
// user function call by name: <varname>(args) — when astType is "function"
|
||||
// and astValue is a string that matches a variable, the parser may have
|
||||
// generated this. Treat it as: invoke the var.
|
||||
if (!RUNTIME_BUILTINS.has(name)) {
|
||||
// Not a known builtin: treat as a user defined function call
|
||||
const args = (tree.astLeaves || []).map(l => compileExpr(l, depth))
|
||||
return `bS.__runFn(${varRef(name)}, [${args.join(",")}])`
|
||||
}
|
||||
|
||||
const args = (tree.astLeaves || []).map(l => compileExpr(l, depth))
|
||||
return `bS.${isValidJsId(name) ? name : `[${jsLit(name)}]`}(${args.join(",")})`
|
||||
}
|
||||
|
||||
const ARITH_OP = {
|
||||
"+": (l,r) => `bS.__add(${l},${r})`,
|
||||
"-": (l,r) => `((${l})-(${r}))`,
|
||||
"*": (l,r) => `((${l})*(${r}))`,
|
||||
"/": (l,r) => `bS.__div(${l},${r})`,
|
||||
"\\": (l,r) => `bS.__intdiv(${l},${r})`,
|
||||
"MOD":(l,r) => `bS.__mod(${l},${r})`,
|
||||
"^": (l,r) => `bS.__pow(${l},${r})`,
|
||||
"==": (l,r) => `((${l})==(${r}))`,
|
||||
"<>": (l,r) => `((${l})!=(${r}))`,
|
||||
"><": (l,r) => `((${l})!=(${r}))`,
|
||||
"<": (l,r) => `((${l})<(${r}))`,
|
||||
">": (l,r) => `((${l})>(${r}))`,
|
||||
"<=": (l,r) => `((${l})<=(${r}))`,
|
||||
"=<": (l,r) => `((${l})<=(${r}))`,
|
||||
">=": (l,r) => `((${l})>=(${r}))`,
|
||||
"=>": (l,r) => `((${l})>=(${r}))`,
|
||||
"AND":(l,r) => `bS.AND(${l},${r})`,
|
||||
"OR": (l,r) => `bS.OR(${l},${r})`,
|
||||
"<<": (l,r) => `((${l})<<(${r}))`,
|
||||
">>": (l,r) => `((${l})>>>(${r}))`,
|
||||
"BAND":(l,r) => `((${l})&(${r}))`,
|
||||
"BOR": (l,r) => `((${l})|(${r}))`,
|
||||
"BXOR":(l,r) => `((${l})^(${r}))`,
|
||||
}
|
||||
const UNARY_OP = {
|
||||
"UNARYMINUS": (a) => `(-(${a}))`,
|
||||
"UNARYPLUS": (a) => `(+(${a}))`,
|
||||
"UNARYLOGICNOT":(a) => `(!(${a}))`,
|
||||
"UNARYBNOT": (a) => `(~(${a}))`,
|
||||
}
|
||||
|
||||
function compileOpExpr(tree, depth) {
|
||||
const op = String(tree.astValue)
|
||||
const leaves = tree.astLeaves || []
|
||||
|
||||
// Unary
|
||||
if (UNARY_OP[op] && (leaves.length === 1 || leaves[1] === undefined)) {
|
||||
return UNARY_OP[op](compileExpr(leaves[0], depth))
|
||||
}
|
||||
|
||||
// Binary arithmetic / comparison / logic
|
||||
if (ARITH_OP[op] && leaves.length === 2) {
|
||||
return ARITH_OP[op](compileExpr(leaves[0], depth), compileExpr(leaves[1], depth))
|
||||
}
|
||||
|
||||
// Generator / range
|
||||
if (op === "TO" && leaves.length === 2) {
|
||||
return `new bS.__ForGen(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)}, 1)`
|
||||
}
|
||||
if (op === "STEP" && leaves.length === 2) {
|
||||
return `bS.STEP(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||
}
|
||||
|
||||
// List ops
|
||||
if ((op === "!" || op === "~" || op === "#") && leaves.length === 2) {
|
||||
const fn = (op === "!") ? "['!']" : (op === "~") ? "['~']" : "['#']"
|
||||
return `bS${fn}(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||
}
|
||||
|
||||
// Assignment as expression — returns the assigned value
|
||||
if (op === "=" && leaves.length === 2) {
|
||||
return "(" + compileAssignExpr(tree, depth) + ")"
|
||||
}
|
||||
if (op === "IN" && leaves.length === 2) {
|
||||
// Used inside FOR/FOREACH; compileFor unwraps these. As a value, treat
|
||||
// as { asgnVarName, asgnValue } so a stray IN still works.
|
||||
const name = jsLit(String(leaves[0].astValue).toUpperCase())
|
||||
const rhs = compileExpr(leaves[1], depth)
|
||||
return `({asgnVarName: ${name}, asgnValue: ${rhs}})`
|
||||
}
|
||||
|
||||
// Functional / monad ops
|
||||
if ((op === ">>=" || op === ">>~" || op === "." || op === "$" ||
|
||||
op === "&" || op === "~<" || op === "<*>" || op === "<$>" ||
|
||||
op === "<~>") && leaves.length === 2) {
|
||||
return `bS[${jsLit(op)}](${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||
}
|
||||
if (op === "@" && leaves.length === 1) {
|
||||
// Monad return as prefix
|
||||
return `bS.MRET(${compileExpr(leaves[0], depth)})`
|
||||
}
|
||||
if (op === "~>") {
|
||||
throw Error("Compiler: bare ~> survived prune (should be usrdefun)")
|
||||
}
|
||||
|
||||
throw Error("Cannot compile op '" + op + "' with " + leaves.length + " operand(s)")
|
||||
}
|
||||
|
||||
function compileLambdaExpr(tree, depth) {
|
||||
// tree.astType === "usrdefun"; tree.astValue holds the body AST; if
|
||||
// tree.astLeaves is non-empty, this is an immediate application.
|
||||
const body = tree.astValue
|
||||
if (!body || !body.astType) throw Error("Malformed usrdefun")
|
||||
|
||||
const arity = lambdaArity(body)
|
||||
const newDepth = depth + 1
|
||||
const params = []
|
||||
for (let i = 0; i < arity; i++) params.push("_a" + (newDepth - 1) + "_" + i)
|
||||
const bodyJs = compileExpr(body, newDepth)
|
||||
const arrow = `((${params.join(",")}) => (${bodyJs}))`
|
||||
|
||||
if (tree.astLeaves && tree.astLeaves.length > 0) {
|
||||
const args = tree.astLeaves.map(l => compileExpr(l, depth))
|
||||
return `${arrow}(${args.join(",")})`
|
||||
}
|
||||
return arrow
|
||||
}
|
||||
|
||||
function compileAssignExpr(tree, depth) {
|
||||
// op "=" with leaves[0] as target, leaves[1] as RHS
|
||||
const lhs = tree.astLeaves[0]
|
||||
const rhs = compileExpr(tree.astLeaves[1], depth)
|
||||
|
||||
if (lhs.astType === "lit") {
|
||||
const name = String(lhs.astValue).toUpperCase()
|
||||
return `(${varRef(name)} = ${rhs})`
|
||||
}
|
||||
// The parser emits "function" or "array" for `A(i,j) = ...` — both mean
|
||||
// "store into element of A".
|
||||
if (lhs.astType === "array" || lhs.astType === "function") {
|
||||
const indices = lhs.astLeaves.map(l => compileExpr(l, depth))
|
||||
return `(bS.__arrSet(${varRef(lhs.astValue)}, [${indices.join(",")}], ${rhs}), ${rhs})`
|
||||
}
|
||||
throw Error("Cannot assign to LHS of type " + lhs.astType)
|
||||
}
|
||||
|
||||
// ---------- statement lowering ----------------------------------------------
|
||||
|
||||
function compilePrintLike(tree, fname, depth) {
|
||||
const leaves = (tree.astLeaves || []).slice()
|
||||
const seps = (tree.astSeps || []).slice()
|
||||
|
||||
let suppressNewline = false
|
||||
if (leaves.length > 0 && leaves[leaves.length - 1] !== undefined &&
|
||||
leaves[leaves.length - 1].astType === "null") {
|
||||
suppressNewline = true
|
||||
leaves.pop()
|
||||
}
|
||||
|
||||
const valueExprs = leaves.map(l => compileExpr(l, depth))
|
||||
if (suppressNewline) valueExprs.push("bS.__PRINT_NONL")
|
||||
const sepArr = seps.slice(0, leaves.length - 1)
|
||||
|
||||
return `bS.${fname}([${valueExprs.join(", ")}], ${jsLit(sepArr)})`
|
||||
}
|
||||
|
||||
function setPc(pc) {
|
||||
if (pc[0] === Infinity) return "pc=[Infinity,0];"
|
||||
return "pc=[" + pc[0] + "," + pc[1] + "];"
|
||||
}
|
||||
|
||||
function compileStatement(tree, lnum, stmt, nextPc) {
|
||||
if (!tree) return setPc(nextPc)
|
||||
if (tree.astType === "null" && tree.astLeaves && tree.astLeaves[0]) {
|
||||
return compileStatement(tree.astLeaves[0], lnum, stmt, nextPc)
|
||||
}
|
||||
|
||||
const isFn = (tree.astType === "function" || tree.astType === "op")
|
||||
const fname = isFn ? String(tree.astValue).toUpperCase() : null
|
||||
|
||||
switch (fname) {
|
||||
case "GOTO": {
|
||||
const target = compileGotoTarget(tree.astLeaves[0])
|
||||
return `pc=${target};`
|
||||
}
|
||||
case "GOSUB": {
|
||||
const target = compileGotoTarget(tree.astLeaves[0])
|
||||
return `gosubStack.push([${nextPc[0]},${nextPc[1]}]); pc=${target};`
|
||||
}
|
||||
case "RETURN":
|
||||
return `pc=gosubStack.pop(); if(!pc) throw new Error("RETURN without GOSUB");`
|
||||
case "END":
|
||||
return "pc=[Infinity,0];"
|
||||
case "IF":
|
||||
return compileIf(tree, lnum, stmt, nextPc)
|
||||
case "ON":
|
||||
return compileOn(tree, lnum, stmt, nextPc)
|
||||
case "FOR":
|
||||
case "FOREACH":
|
||||
return compileFor(tree, lnum, stmt, nextPc, fname === "FOREACH")
|
||||
case "NEXT":
|
||||
return compileNext(tree, lnum, stmt, nextPc)
|
||||
case "READ": {
|
||||
const target = tree.astLeaves[0]
|
||||
if (target.astType !== "lit") throw Error("READ: target must be a variable")
|
||||
return `${varRef(target.astValue)}=bS.__readData(); ${setPc(nextPc)}`
|
||||
}
|
||||
case "RESTORE":
|
||||
return `bS.__state.dataCursor=0; ${setPc(nextPc)}`
|
||||
case "DATA":
|
||||
case "LABEL":
|
||||
return setPc(nextPc) // harvested at compile time
|
||||
case "DIM":
|
||||
return compileDim(tree, lnum, stmt, nextPc)
|
||||
case "PRINT":
|
||||
case "EMIT":
|
||||
return `${compilePrintLike(tree, fname, 0)}; ${setPc(nextPc)}`
|
||||
case "OPTIONBASE":
|
||||
return `bS.OPTIONBASE(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||
case "OPTIONDEBUG":
|
||||
return `bS.OPTIONDEBUG(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||
case "OPTIONTRACE":
|
||||
return `bS.OPTIONTRACE(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||
case "INPUT": {
|
||||
// INPUT <var> -> read into var
|
||||
const target = tree.astLeaves[tree.astLeaves.length - 1]
|
||||
if (target.astType !== "lit") throw Error("INPUT: target must be a variable")
|
||||
return `${varRef(target.astValue)}=bS.INPUT(); ${setPc(nextPc)}`
|
||||
}
|
||||
case "=":
|
||||
return `${compileAssignExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||
case "IN":
|
||||
// bare IN as a statement is unusual but harmless
|
||||
return `${compileExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||
case "REM":
|
||||
return setPc(nextPc)
|
||||
}
|
||||
|
||||
// Default: evaluate as an expression for side effect, then advance
|
||||
return `${compileExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||
}
|
||||
|
||||
function compileGotoTarget(leaf) {
|
||||
// Always route through __resolveTarget so non-existent line numbers snap
|
||||
// upward to the next existing line — matching basic.js's main loop,
|
||||
// which increments lnum until it finds a populated cmdbuf entry.
|
||||
if (leaf.astType === "num") return `bS.__resolveTarget(${Number(leaf.astValue)})`
|
||||
if (leaf.astType === "string") return `bS.__resolveTarget(${jsLit(leaf.astValue)})`
|
||||
if (leaf.astType === "lit") {
|
||||
const name = String(leaf.astValue)
|
||||
return `bS.__resolveTarget(bS.__state.gotoLabels[${jsLit(name)}]!==undefined ? ${jsLit(name)} : ${varRef(name)})`
|
||||
}
|
||||
return `bS.__resolveTarget(${compileExpr(leaf, 0)})`
|
||||
}
|
||||
|
||||
function compileIf(tree, lnum, stmt, nextPc) {
|
||||
const test = compileExpr(tree.astLeaves[0], 0)
|
||||
const thenStmt = compileStatement(tree.astLeaves[1], lnum, stmt, nextPc)
|
||||
const elseStmt = (tree.astLeaves[2])
|
||||
? compileStatement(tree.astLeaves[2], lnum, stmt, nextPc)
|
||||
: setPc(nextPc)
|
||||
return `if(bS.__test(${test})){${thenStmt}}else{${elseStmt}}`
|
||||
}
|
||||
|
||||
function compileOn(tree, lnum, stmt, nextPc) {
|
||||
// children: testExpr, jumpFnLit, target0, target1, ...
|
||||
const testExpr = compileExpr(tree.astLeaves[0], 0)
|
||||
const jmpFn = String(tree.astLeaves[1].astValue).toUpperCase()
|
||||
const targets = tree.astLeaves.slice(2)
|
||||
|
||||
const cases = targets.map((t, i) => {
|
||||
const tgt = compileGotoTarget(t)
|
||||
if (jmpFn === "GOSUB") {
|
||||
return `case ${i}: gosubStack.push([${nextPc[0]},${nextPc[1]}]); pc=${tgt}; break;`
|
||||
}
|
||||
return `case ${i}: pc=${tgt}; break;`
|
||||
})
|
||||
return `{const _o=(${testExpr})-bS.__state.indexBase; switch(_o){${cases.join(" ")} default: ${setPc(nextPc)}}}`
|
||||
}
|
||||
|
||||
function compileFor(tree, lnum, stmt, nextPc, isForEach) {
|
||||
const child = tree.astLeaves[0]
|
||||
if (child.astType !== "op" || (child.astValue !== "=" && child.astValue !== "IN")) {
|
||||
throw Error("FOR/FOREACH: expected = or IN, got " + child.astType + ":" + child.astValue)
|
||||
}
|
||||
const varname = String(child.astLeaves[0].astValue).toUpperCase()
|
||||
let iter = compileExpr(child.astLeaves[1], 0)
|
||||
if (isForEach) {
|
||||
// ensure we coerce generators into arrays for FOREACH semantics
|
||||
iter = `(function(_x){return bS.__isGenerator(_x)?bS.__genToArray(_x):_x})(${iter})`
|
||||
}
|
||||
// Pass nextPc — the PC of the loop body's first statement — so NEXT can
|
||||
// jump straight back without relying on fall-through.
|
||||
return `bS.__forSetup(${jsLit(varname)}, ${iter}, ${nextPc[0]}, ${nextPc[1]}); ${setPc(nextPc)}`
|
||||
}
|
||||
|
||||
function compileNext(tree, lnum, stmt, nextPc) {
|
||||
let argExpr = "undefined"
|
||||
const leaves = tree.astLeaves || []
|
||||
if (leaves.length === 1 && leaves[0] && leaves[0].astType === "lit") {
|
||||
argExpr = jsLit(String(leaves[0].astValue).toUpperCase())
|
||||
}
|
||||
return `{const _n=bS.__forNext(${argExpr}); if(_n){pc=_n;}else{${setPc(nextPc)}}}`
|
||||
}
|
||||
|
||||
function compileDim(tree, lnum, stmt, nextPc) {
|
||||
// tree.astLeaves contains array constructor calls: each leaf is either
|
||||
// an `array` node OR a `function` node (the parser doesn't distinguish
|
||||
// `A(5)` from a function call until runtime). astValue is the variable
|
||||
// name and astLeaves are the dimension expressions.
|
||||
const stmts = []
|
||||
for (let i = 0; i < tree.astLeaves.length; i++) {
|
||||
const leaf = tree.astLeaves[i]
|
||||
if (leaf.astType !== "array" && leaf.astType !== "function") {
|
||||
throw Error("DIM: expected array decl, got " + leaf.astType)
|
||||
}
|
||||
const name = String(leaf.astValue).toUpperCase()
|
||||
const dims = leaf.astLeaves.map(l => compileExpr(l, 0))
|
||||
stmts.push(`${varRef(name)}=bS.__dim([${dims.join(",")}]);`)
|
||||
}
|
||||
return stmts.join(" ") + " " + setPc(nextPc)
|
||||
}
|
||||
|
||||
// ---------- top-level entry --------------------------------------------------
|
||||
|
||||
// Set of builtin names exposed by tbas.mjs. Used to decide whether a `lit`
|
||||
// in expression position is a variable or a function reference.
|
||||
const RUNTIME_BUILTINS = new Set([
|
||||
"PRINT","EMIT","INPUT","CIN",
|
||||
"ABS","SGN","INT","FLOOR","CEIL","FIX","ROUND","SQR","CBR",
|
||||
"SIN","COS","TAN","ASN","ACO","ATN","SINH","COSH","TANH",
|
||||
"EXP","LOG","MIN","MAX","RND",
|
||||
"SPC","LEFT","RIGHT","MID","CHR",
|
||||
"LEN","HEAD","TAIL","INIT","LAST","MAP","FOLD","FILTER","ARRAY",
|
||||
"CLS","CLPX","PLOT","GOTOYX","TEXTFORE","TEXTBACK",
|
||||
"POKE","PEEK","GETKEYSDOWN","CPUT","CGET","CSTA",
|
||||
"TYPEOF","OPTIONBASE","OPTIONDEBUG","OPTIONTRACE",
|
||||
"MRET","MLIST","MJOIN",
|
||||
"AND","OR","NOT",
|
||||
"DO","CLEAR","END","TO","STEP",
|
||||
"FOR","FOREACH","NEXT","IF","ON","GOTO","GOSUB","RETURN",
|
||||
"DIM","DATA","READ","RESTORE","LABEL","REM",
|
||||
"TEST",
|
||||
])
|
||||
|
||||
bS._compileImpl = function(outpath) {
|
||||
if (typeof cmdbuf === "undefined") throw Error("compile.js: cmdbuf not available")
|
||||
if (typeof bF === "undefined") throw Error("compile.js: bF not available")
|
||||
if (typeof bF._interpretLine !== "function") throw Error("compile.js: bF._interpretLine not available")
|
||||
|
||||
// Reset parser-side state so we don't pollute the live interpreter
|
||||
if (typeof lambdaBoundVars !== "undefined") lambdaBoundVars.length = 0
|
||||
const savedPrescan = (typeof prescan !== "undefined") ? prescan : false
|
||||
if (typeof prescan !== "undefined") prescan = true // suppress execution of LABEL/DATA prescan side-effects
|
||||
|
||||
// ---- pass 1: parse every line ----
|
||||
const programTrees = [] // [lnum] -> array of statements
|
||||
for (let lnum = 0; lnum < cmdbuf.length; lnum++) {
|
||||
const linestr = cmdbuf[lnum]
|
||||
if (linestr === undefined) continue
|
||||
const trees = bF._interpretLine(lnum, String(linestr).trim())
|
||||
if (trees !== undefined) programTrees[lnum] = trees
|
||||
}
|
||||
if (typeof prescan !== "undefined") prescan = savedPrescan
|
||||
|
||||
// ---- pass 2: ordered list of populated lnums and successor table ----
|
||||
const linenums = []
|
||||
for (let lnum = 0; lnum < programTrees.length; lnum++) {
|
||||
if (programTrees[lnum] !== undefined) linenums.push(lnum)
|
||||
}
|
||||
|
||||
function nextPcOf(idx, stmtIdx) {
|
||||
const lnum = linenums[idx]
|
||||
const stmts = programTrees[lnum]
|
||||
if (stmtIdx + 1 < stmts.length) return [lnum, stmtIdx + 1]
|
||||
if (idx + 1 < linenums.length) return [linenums[idx + 1], 0]
|
||||
return [Infinity, 0]
|
||||
}
|
||||
|
||||
// ---- pass 3: harvest DATA constants and LABEL definitions ----
|
||||
const dataConsts = []
|
||||
const labelMap = {}
|
||||
for (let i = 0; i < linenums.length; i++) {
|
||||
const lnum = linenums[i]
|
||||
const stmts = programTrees[lnum]
|
||||
for (let s = 0; s < stmts.length; s++) {
|
||||
const t = stmts[s]
|
||||
if (!t) continue
|
||||
if (t.astValue === "DATA") {
|
||||
for (let k = 0; k < t.astLeaves.length; k++) {
|
||||
dataConsts.push(literalValue(t.astLeaves[k]))
|
||||
}
|
||||
} else if (t.astValue === "LABEL") {
|
||||
const lblNode = t.astLeaves[0]
|
||||
if (!lblNode) throw Error("LABEL with no name on line " + lnum)
|
||||
const lblName = String(lblNode.astValue)
|
||||
labelMap[lblName] = [lnum, s]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pass 4: emit case bodies ----
|
||||
const cases = []
|
||||
for (let i = 0; i < linenums.length; i++) {
|
||||
const lnum = linenums[i]
|
||||
const stmts = programTrees[lnum]
|
||||
for (let s = 0; s < stmts.length; s++) {
|
||||
const next = nextPcOf(i, s)
|
||||
const body = compileStatement(stmts[s], lnum, s, next)
|
||||
cases.push(` case ${lnum}*32+${s}: { ${body} break; }`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pass 5: assemble final output ----
|
||||
const firstPc = (linenums.length > 0) ? `[${linenums[0]},0]` : `[Infinity,0]`
|
||||
const labelMapJs = "{" + Object.keys(labelMap).map(k =>
|
||||
`${jsLit(k)}: [${labelMap[k][0]}, ${labelMap[k][1]}]`
|
||||
).join(", ") + "}"
|
||||
|
||||
const out =
|
||||
`// Compiled by Terran BASIC -> JS compiler (assets/disk0/tbas/compile.js)
|
||||
// Source line count: ${linenums.length}
|
||||
let bS = require("tbas")
|
||||
bS.__reset()
|
||||
bS.__data(${jsLit(dataConsts)})
|
||||
bS.__labels(${labelMapJs})
|
||||
bS.__setLines(${jsLit(linenums)})
|
||||
let pc = ${firstPc}
|
||||
const gosubStack = []
|
||||
while (pc[0] !== Infinity) {
|
||||
switch (pc[0]*32 + pc[1]) {
|
||||
${cases.join("\n")}
|
||||
default: pc = [Infinity, 0]; break;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ---- write to disk via basic.js's fs (writes under BASIC_HOME_PATH) ----
|
||||
const opened = fs.open(outpath, "W")
|
||||
if (!opened) throw Error("Cannot open " + outpath + " for writing")
|
||||
fs.write(out)
|
||||
return out.length
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -19,9 +19,9 @@ var Note = (function() {
|
||||
if (flats[s] !== names[s]) t[flats[s] + oct] = n(oct, s);
|
||||
}
|
||||
}
|
||||
t.OFF = 0x0000; // key-off
|
||||
t.CUT = 0xFFFE; // note cut (immediate)
|
||||
t.NOP = 0xFFFF; // no-op (empty row)
|
||||
t.NOP = 0x0000; // no-op (empty row)
|
||||
t.OFF = 0x0001; // key-off
|
||||
t.CUT = 0x0002; // note cut (immediate)
|
||||
return t;
|
||||
}());
|
||||
|
||||
|
||||
@@ -55,10 +55,12 @@ class PmemFSfile {
|
||||
// string representation (preferable)
|
||||
if (typeof bytes === 'string' || bytes instanceof String) {
|
||||
this.data = bytes
|
||||
this.length = bytes.length
|
||||
}
|
||||
// Javascript array OR JVM byte[]
|
||||
else if (Array.isArray(bytes) || bytes.toString().startsWith("[B")) {
|
||||
this.bdata = bytes[i]
|
||||
this.bdata = bytes
|
||||
this.length = bytes.length
|
||||
}
|
||||
else {
|
||||
throw Error("Invalid type for directory")
|
||||
@@ -76,10 +78,10 @@ class PmemFSfile {
|
||||
|
||||
dataAsBytes() {
|
||||
if (this.bdata !== undefined) return this.bdata
|
||||
this.bdata = new Int8Array(this.data.length)
|
||||
this.bdata = new Uint8Array(this.data.length)
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
let p = this.data.charCodeAt(i)
|
||||
this.bdata[i] = (p > 127) ? p - 255 : p
|
||||
this.bdata[i] = p
|
||||
}
|
||||
return this.bdata
|
||||
}
|
||||
@@ -147,10 +149,12 @@ _TVDOS.variables = {
|
||||
LANG: "EN",
|
||||
KEYBOARD: "us_qwerty",
|
||||
PATH: "\\tvdos\\bin;\\home",
|
||||
INCLPATH: "\\tvdos\\include;\\home",
|
||||
PATHEXT: ".com;.bat;.app;.js;.alias",
|
||||
HELPPATH: "\\tvdos\\help",
|
||||
OS_NAME: "TSVM Disk Operating System",
|
||||
OS_VERSION: _TVDOS.VERSION
|
||||
OS_VERSION: _TVDOS.VERSION,
|
||||
USERCONFIGPATH: "\\home\\config",
|
||||
};
|
||||
Object.freeze(_TVDOS);
|
||||
|
||||
@@ -162,16 +166,16 @@ class TVDOSFileDescriptor {
|
||||
|
||||
constructor(path0, driverID) {
|
||||
if (path0.startsWith("$")) {
|
||||
let path1 = path0.substring(3)
|
||||
let slashPos = path1.indexOf("/")
|
||||
let path1 = path0.replaceAll("/", "\\").substring(3)
|
||||
let slashPos = path1.indexOf("\\")
|
||||
let devName = path1.substring(0, (slashPos < 0) ? path1.length : slashPos)
|
||||
|
||||
if (!files.reservedNames.includes(devName)) {
|
||||
throw Error(`${devName} is not a valid device file`)
|
||||
}
|
||||
|
||||
this._driveLetter = undefined
|
||||
this._path = path0
|
||||
this._driveLetter = '$'
|
||||
this._path = '\\' + path1
|
||||
this._driverID = `DEV${devName}`
|
||||
this._driver = _TVDOS.DRV.FS[`DEV${devName}`] // can't just put `driverID` here
|
||||
}
|
||||
@@ -937,8 +941,9 @@ _TVDOS.DRV.FS.DEVTMP.bread = (fd) => {
|
||||
_TVDOS.DRV.FS.DEVTMP.pread = (fd, ptr, count, offset) => {
|
||||
if (_TVDOS.TMPFS[fd.path] === undefined) throw Error(`No such file: ${fd.fullPath}`)
|
||||
let str = _TVDOS.TMPFS[fd.path].dataAsString()
|
||||
for (let i = 0; i < count - (offset || 0); i++) {
|
||||
sys.poke(ptr + i, String.charCodeAt(i + (offset || 0)))
|
||||
let off = offset || 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
sys.poke(ptr + i, str.charCodeAt(off + i))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,6 +991,7 @@ _TVDOS.DRV.FS.DEVTMP.remove = (fd) => {
|
||||
return true
|
||||
}
|
||||
_TVDOS.DRV.FS.DEVTMP.exists = (fd) => (_TVDOS.TMPFS[fd.path] !== undefined)
|
||||
_TVDOS.DRV.FS.DEVTMP.getFileLen = (fd) => (_TVDOS.TMPFS[fd.path].length)
|
||||
|
||||
Object.freeze(_TVDOS.DRV.FS.DEVTMP)
|
||||
|
||||
@@ -1108,13 +1114,18 @@ inputwork.repeatCount = 0;
|
||||
* where:
|
||||
* "key_down", <key symbol string>, <repeat count>, keycode0, keycode1 .. keycode7
|
||||
* "key_change", <key symbol string (what went up)>, 0, keycode0, keycode1 .. keycode7 (remaining keys that are held down)
|
||||
* "mouse_down", pos-x, pos-y, 1 // yes there's only one mouse button :p
|
||||
* "mouse_up", pos-x, pos-y, 0
|
||||
* "mouse_move", pos-x, pos-y, <button down?>, oldpos-x, oldpos-y
|
||||
* "mouse_down", pos-x, pos-y, <button mask: 1=left, 2=right, 4=middle>, keycode0..keycode7
|
||||
* "mouse_up", pos-x, pos-y, <button mask of the released button>, keycode0..keycode7
|
||||
* "mouse_move", pos-x, pos-y, <currently-held button mask>, oldpos-x, oldpos-y, keycode0..keycode7
|
||||
* "mouse_wheel", pos-x, pos-y, <-1 for wheel up, +1 for wheel down>, keycode0..keycode7
|
||||
*
|
||||
* Button mask values come from MMIO[36] bits 0..2 (terranmon.txt:52-58). The wheel
|
||||
* bits (6, 7) latch in hardware and clear on read, so a one-shot detent fires once.
|
||||
* Every mouse event carries the currently-held key buffer (same shape as key_down)
|
||||
* so handlers can detect modifiers like Shift+wheel via `event.includes(<keysym>)`.
|
||||
*/
|
||||
input.withEvent = function(callback) {
|
||||
|
||||
// TODO mouse event
|
||||
function arrayEq(a,b) {
|
||||
for (let i = 0; i < a.length; ++i) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
@@ -1135,7 +1146,33 @@ input.withEvent = function(callback) {
|
||||
|
||||
sys.poke(-40, 255);
|
||||
let keys = [sys.peek(-41),sys.peek(-42),sys.peek(-43),sys.peek(-44),sys.peek(-45),sys.peek(-46),sys.peek(-47),sys.peek(-48)];
|
||||
let mouse = [sys.peek(-33) | (sys.peek(-34) << 8), sys.peek(-35) | (sys.peek(-36) << 8), sys.peek(-37)];
|
||||
let mx = (sys.peek(-33) & 0xFF) | ((sys.peek(-34) & 0xFF) << 8);
|
||||
let my = (sys.peek(-35) & 0xFF) | ((sys.peek(-36) & 0xFF) << 8);
|
||||
let mb = sys.peek(-37) & 0xFF; // bits 0..2 = L/R/M held, bit 6 = wheel up, bit 7 = wheel down
|
||||
let mouse = [mx, my, mb];
|
||||
|
||||
// --- mouse dispatch ---
|
||||
let oldMouse = inputwork.oldMouse;
|
||||
let hasOld = oldMouse && oldMouse.length === 3;
|
||||
let oldBtns = hasOld ? (oldMouse[2] & 0x07) : 0;
|
||||
let curBtns = mb & 0x07;
|
||||
let wheelUp = (mb & 0x40) !== 0;
|
||||
let wheelDn = (mb & 0x80) !== 0;
|
||||
|
||||
if (wheelUp) callback(["mouse_wheel", mx, my, -1].concat(keys));
|
||||
if (wheelDn) callback(["mouse_wheel", mx, my, 1].concat(keys));
|
||||
|
||||
let pressed = curBtns & ~oldBtns;
|
||||
let released = oldBtns & ~curBtns;
|
||||
for (let b = 1; b <= 4; b <<= 1) {
|
||||
if (pressed & b) callback(["mouse_down", mx, my, b].concat(keys));
|
||||
if (released & b) callback(["mouse_up", mx, my, b].concat(keys));
|
||||
}
|
||||
if (hasOld && (mx !== oldMouse[0] || my !== oldMouse[1])) {
|
||||
callback(["mouse_move", mx, my, curBtns, oldMouse[0], oldMouse[1]].concat(keys));
|
||||
}
|
||||
// --- end mouse dispatch ---
|
||||
|
||||
let keyChanged = !arrayEq(keys, inputwork.oldKeys)
|
||||
let keyDiff = arrayDiff(keys, inputwork.oldKeys)
|
||||
|
||||
@@ -1405,9 +1442,6 @@ let requireFromMemory = (ptr) => {
|
||||
}*/
|
||||
|
||||
|
||||
var GL = require("A:/tvdos/include/gl.mjs")
|
||||
|
||||
|
||||
// @param cmdsrc JS source code
|
||||
// @param args arguments for the program, must be Array, and args[0] is always the name of the program, e.g.
|
||||
// for command line 'echo foo bar', args[0] must be 'echo'
|
||||
@@ -1420,7 +1454,7 @@ var execApp = (cmdsrc, args, appname) => {
|
||||
`var ${appname}=function(exec_args){${injectIntChk(cmdsrc, intchkFunName)}\n};` +
|
||||
`${appname}`); // making 'exec_args' a app-level global
|
||||
|
||||
execAppPrg(args);
|
||||
return execAppPrg(args);
|
||||
}
|
||||
|
||||
|
||||
@@ -1437,9 +1471,40 @@ try {
|
||||
serial.println("Warning: Could not load HSDPA driver: " + e.message)
|
||||
}
|
||||
|
||||
// Boot script
|
||||
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
|
||||
// Boot script. The work is split across two files:
|
||||
// \commandrc -- environment (`set` commands); run in EVERY context.
|
||||
// \AUTOEXEC.BAT -- per-console launch (IME + interactive shell).
|
||||
// vtmgr re-evaluates TVDOS.SYS inside each per-VT pane; a pane sets
|
||||
// _TVDOS_IS_VT_PANE so it only replays the environment here and leaves the
|
||||
// AUTOEXEC launch to vtmgr's pane bootstrap (which avoids recursively
|
||||
// spawning vtmgr inside a pane).
|
||||
{
|
||||
let cmdsrc = files.open("A:/tvdos/bin/command.js").sread()
|
||||
let runBatch = (path) => eval(`var _BAT=function(exec_args){${cmdsrc}\n};_BAT`)(["", "-c", path])
|
||||
|
||||
let cmdfile = files.open("A:/tvdos/bin/command.js")
|
||||
eval(`var _AUTOEXEC=function(exec_args){${cmdfile.sread()}\n};` +
|
||||
`_AUTOEXEC`)(["", "-c", "\\AUTOEXEC.BAT"])
|
||||
// Environment first, boot and pane alike. Gives every pane the same
|
||||
// PATH / KEYBOARD / etc. natively, with no env-snapshot replay needed.
|
||||
// \commandrc has no .BAT extension (so command.js's batch-file path,
|
||||
// which keys off the extension, won't pick it up); run it line-by-line.
|
||||
// `set` mutates the shared _TVDOS.variables, so the effect persists across
|
||||
// the per-line shell invocations. Skip blanks and `rem` comments.
|
||||
let rcFile = files.open("A:/commandrc")
|
||||
if (rcFile.exists) {
|
||||
rcFile.sread().split('\n').forEach((line) => {
|
||||
let t = line.trim()
|
||||
if (t.length > 0 && !/^rem(\s|$)/i.test(t)) runBatch(line)
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof _TVDOS_IS_VT_PANE === "undefined" || !_TVDOS_IS_VT_PANE) {
|
||||
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
|
||||
// Boot console: hand the screen to the virtual-console multiplexer.
|
||||
// When it exits (Alt-0), fall through to AUTOEXEC so the console is
|
||||
// never left bare.
|
||||
runBatch("tvdos/sbin/vtmgr")
|
||||
runBatch("\\AUTOEXEC.BAT")
|
||||
}
|
||||
else {
|
||||
serial.println(`TVDOS.SYS re-initialised in VT pane on VM ${sys.getVmId()}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,18 @@ function makeHash() {
|
||||
const shellID = makeHash()
|
||||
|
||||
function print_prompt_text() {
|
||||
// VT pane indicator: shown for VT 2..6, not VT 1 (the default) so the
|
||||
// unmodified prompt is what users see when they never touch virtual
|
||||
// consoles. VT_NUM is set by vtmgr's pane bootstrap.
|
||||
let vtPrefix = ""
|
||||
if (typeof VT_NUM !== "undefined" && VT_NUM > 1) vtPrefix = "[" + VT_NUM + "] "
|
||||
if (goFancy) {
|
||||
if (vtPrefix) {
|
||||
con.color_pair(161,253)
|
||||
print(`\u00DD${VT_NUM}`)
|
||||
con.color_pair(253,161)
|
||||
con.addch(16);con.curs_right()
|
||||
}
|
||||
con.color_pair(239,161)
|
||||
print(" "+CURRENT_DRIVE+":")
|
||||
con.color_pair(161,253)
|
||||
@@ -49,9 +60,9 @@ function print_prompt_text() {
|
||||
else {
|
||||
// con.color_pair(253,255)
|
||||
if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined)
|
||||
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
|
||||
print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
|
||||
else
|
||||
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
|
||||
print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,56 +88,31 @@ function printmotd() {
|
||||
let motd = motdFile.sread().trim()
|
||||
let width = con.getmaxyx()[1]
|
||||
|
||||
let ts = require("typesetter")
|
||||
|
||||
if (goFancy) {
|
||||
let margin = 4
|
||||
let internalWidth = width - 2*margin
|
||||
let textWidth = internalWidth - 2 // one space of padding inside each ribbon edge
|
||||
|
||||
con.color_pair(255,253) // white text, transparent back (initial ribbon)
|
||||
|
||||
let [cy, cx] = con.getyx()
|
||||
|
||||
con.mvaddch(cy, 4, 16);con.curs_right();print(' ')
|
||||
|
||||
const PCX_INIT = margin - 2
|
||||
let tcnt = 0
|
||||
let pcx = PCX_INIT
|
||||
con.color_pair(240,253) // black text, white back (first line of text)
|
||||
while (tcnt <= motd.length) {
|
||||
let char = motd.charAt(tcnt)
|
||||
|
||||
if (char != '\n') {
|
||||
// prevent the line starting from ' '
|
||||
if (pcx != PCX_INIT || char != ' ') {
|
||||
print(motd.charAt(tcnt))
|
||||
}
|
||||
pcx += 1
|
||||
}
|
||||
|
||||
if ('\n' == char || pcx % internalWidth == 0 && pcx != 0 || tcnt == motd.length) {
|
||||
// current line ending
|
||||
let [_, ncx] = con.getyx()
|
||||
for (let k = 0; k < width - margin - ncx + 1; k++) print(' ')
|
||||
con.color_pair(255,253) // white text, transparent back
|
||||
con.addch(17);println()
|
||||
|
||||
if (tcnt == motd.length) break
|
||||
|
||||
// next line header
|
||||
let [ncy, __] = con.getyx()
|
||||
con.color_pair(255,253) // white text, transparent back
|
||||
con.mvaddch(ncy, 4, 16);con.curs_right();print(' ');con.color_pair(240,253) // black text, white back (subsequent lines of the text)
|
||||
pcx = PCX_INIT
|
||||
}
|
||||
|
||||
tcnt += 1
|
||||
}
|
||||
|
||||
let lines = ts.typeset(motd, textWidth)
|
||||
lines.forEach(line => {
|
||||
let [cy, _cx] = con.getyx()
|
||||
con.color_pair(255,253) // ribbon edge: white text, transparent back
|
||||
con.mvaddch(cy, margin, 16); con.curs_right()
|
||||
print(' ')
|
||||
con.color_pair(240,253) // body: black text, white back
|
||||
print(line)
|
||||
con.color_pair(255,253)
|
||||
print(' ')
|
||||
con.addch(17); println()
|
||||
})
|
||||
con.reset_graphics()
|
||||
}
|
||||
else {
|
||||
println()
|
||||
println(motd)
|
||||
let lines = ts.typeset(motd, width)
|
||||
lines.forEach(line => println(line))
|
||||
}
|
||||
|
||||
println()
|
||||
@@ -203,6 +189,19 @@ shell.replaceVarCall = function(value) {
|
||||
shell.getPwd = function() { return shell_pwd; }
|
||||
shell.getPwdString = function() { return "\\" + (shell_pwd.concat([""])).join("\\"); }
|
||||
shell.getCurrentDrive = function() { return CURRENT_DRIVE; }
|
||||
shell.runningScriptPaths = []
|
||||
shell.getFilePath = function() {
|
||||
return shell.runningScriptPaths[shell.runningScriptPaths.length - 1]
|
||||
}
|
||||
shell.getFileDir = function() {
|
||||
let p = shell.runningScriptPaths[shell.runningScriptPaths.length - 1]
|
||||
if (p === undefined) return undefined
|
||||
let lastSlash = Math.max(p.lastIndexOf('\\'), p.lastIndexOf('/'))
|
||||
if (lastSlash < 0) return p
|
||||
// root of a drive (e.g. "A:\foo.js" -> "A:\")
|
||||
if (lastSlash === 2 && p[1] === ':') return p.substring(0, 3)
|
||||
return p.substring(0, lastSlash)
|
||||
}
|
||||
// example input: echo "the string" > subdir\test.txt
|
||||
shell.parse = function(input) {
|
||||
let tokens = []
|
||||
@@ -577,8 +576,76 @@ shell.coreutils = {
|
||||
ver: function(args) {
|
||||
println(welcome_text)
|
||||
},
|
||||
which: function(args) {
|
||||
if (args[1] === undefined) {
|
||||
printerrln(`Usage: ${args[0].toUpperCase()} program_name`)
|
||||
return 1
|
||||
}
|
||||
let cmd = args[1]
|
||||
|
||||
if (shell.coreutils[cmd.toLowerCase()] !== undefined) {
|
||||
println(`${cmd}: shell built-in command`)
|
||||
return 0
|
||||
}
|
||||
|
||||
var fileExists = false
|
||||
var searchFile
|
||||
var searchPath = ""
|
||||
|
||||
if (shell.isValidDriveLetter(cmd[0]) && cmd[1] == ':') {
|
||||
searchFile = files.open(cmd)
|
||||
searchPath = trimStartRevSlash(searchFile.path)
|
||||
fileExists = searchFile.exists
|
||||
}
|
||||
else {
|
||||
var searchDir = (cmd.startsWith("/")) ? [""] : ["/"+shell_pwd.join("/")].concat(_TVDOS.getPath())
|
||||
|
||||
var pathExt = []
|
||||
if (cmd.split(".")[1] === undefined)
|
||||
_TVDOS.variables.PATHEXT.split(';').forEach(function(it) { pathExt.push(it); pathExt.push(it.toUpperCase()); })
|
||||
else
|
||||
pathExt.push("")
|
||||
|
||||
searchLoop:
|
||||
for (var i = 0; i < searchDir.length; i++) {
|
||||
for (var j = 0; j < pathExt.length; j++) {
|
||||
let search = searchDir[i]; if (!search.endsWith('\\')) search += '\\'
|
||||
searchPath = trimStartRevSlash(search + cmd + pathExt[j])
|
||||
|
||||
searchFile = files.open(`${CURRENT_DRIVE}:\\${searchPath}`)
|
||||
if (searchFile.exists) {
|
||||
fileExists = true
|
||||
break searchLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileExists) {
|
||||
printerrln(`${cmd}: not found`)
|
||||
return 1
|
||||
}
|
||||
|
||||
println(searchFile.fullPath)
|
||||
return 0
|
||||
},
|
||||
panic: function(args) {
|
||||
throw Error("Panicking command.js")
|
||||
},
|
||||
chvt: function(args) {
|
||||
// Request a switch to another virtual console. Only meaningful when
|
||||
// running inside a pane spawned by vtmgr (VT_CTRL_ADDR is set by the
|
||||
// pane bootstrap). Outside that environment this is a no-op error.
|
||||
if (args[1] === undefined) { printerrln("Usage: chvt N (1..6)"); return 1 }
|
||||
let n = parseInt(args[1])
|
||||
if (isNaN(n) || n < 1 || n > 6) { printerrln("chvt: N must be in 1..6"); return 1 }
|
||||
if (typeof VT_CTRL_ADDR === "undefined") {
|
||||
printerrln("chvt: not running under vtmgr (no VT context)"); return 1
|
||||
}
|
||||
// CTRL_SWITCH_REQUEST is byte +1 of the shared CTRL area. Dispatcher
|
||||
// picks this up on its next 30 Hz tick and performs the switch.
|
||||
sys.poke(VT_CTRL_ADDR + 1, n)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
// define command aliases here
|
||||
@@ -590,14 +657,19 @@ shell.coreutils.ls = shell.coreutils.dir
|
||||
shell.coreutils.time = shell.coreutils.date
|
||||
shell.coreutils.md = shell.coreutils.mkdir
|
||||
shell.coreutils.move = shell.coreutils.mv
|
||||
shell.coreutils.where = shell.coreutils.which
|
||||
// end of command aliases
|
||||
Object.freeze(shell.coreutils)
|
||||
shell.stdio = {
|
||||
out: {
|
||||
print: function(s) { sys.print(s) },
|
||||
println: function(s) { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") },
|
||||
printerr: function(s) { sys.print("\x1B[31m"+s+"\x1B[m") },
|
||||
printerrln: function(s) { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") },
|
||||
// When running inside a vtmgr virtual console, __VT_OUT routes output
|
||||
// to the pane's text-plane buffer instead of the physical GPU (which
|
||||
// the compositor would otherwise overwrite). Outside a VT the hook is
|
||||
// absent and these fall through to sys.print exactly as before.
|
||||
print: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.print(s); else sys.print(s) },
|
||||
println: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.println(s); else { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") } },
|
||||
printerr: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerr(s); else sys.print("\x1B[31m"+s+"\x1B[m") },
|
||||
printerrln: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerrln(s); else { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") } },
|
||||
},
|
||||
pipe: {
|
||||
print: function(s) { if (shell.getPipe() === undefined) throw Error("No pipe opened"); shell.appendToCurrentPipe(s); },
|
||||
@@ -614,13 +686,25 @@ require = function(path) {
|
||||
if (path[1] == ":") return shell.require(path)
|
||||
else {
|
||||
// if the path starts with ".", look for the current directory
|
||||
// if the path starts with [A-Za-z0-9], look for the DOSDIR/includes
|
||||
// if the path starts with [A-Za-z0-9], search through INCLPATH
|
||||
if (path[0] == '.') return shell.require(shell.resolvePathInput(path).full + ".mjs")
|
||||
else return shell.require(`A:${_TVDOS.variables.DOSDIR}/include/${path}.mjs`)
|
||||
else {
|
||||
let inclDirs = (_TVDOS.variables.INCLPATH || "").split(';').filter(function(it) { return it.length > 0 })
|
||||
for (let i = 0; i < inclDirs.length; i++) {
|
||||
let dir = inclDirs[i]
|
||||
if (!dir.endsWith('\\') && !dir.endsWith('/')) dir += '\\'
|
||||
let candidate = `${CURRENT_DRIVE}:${dir}${path}.mjs`
|
||||
if (files.open(candidate).exists) return shell.require(candidate)
|
||||
}
|
||||
// no match found; defer to shell.require with the first entry so the error mentions a sensible path
|
||||
let firstDir = inclDirs[0] || `${_TVDOS.variables.DOSDIR}\\include`
|
||||
if (!firstDir.endsWith('\\') && !firstDir.endsWith('/')) firstDir += '\\'
|
||||
return shell.require(`${CURRENT_DRIVE}:${firstDir}${path}.mjs`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shell.execute = function(line) {
|
||||
shell.execute = function(line, nameOverride) {
|
||||
if (0 == line.size) return
|
||||
let parsedTokens = shell.parse(line) // echo, "hai", |, less
|
||||
let statements = [] // [[echo, "hai"], [less]]
|
||||
@@ -746,6 +830,8 @@ shell.execute = function(line) {
|
||||
let programCode = searchFile.sread()
|
||||
let extension = searchFile.extension.toUpperCase()
|
||||
|
||||
shell.runningScriptPaths.push(searchFile.fullPath)
|
||||
try {
|
||||
if ("BAT" == extension) {
|
||||
// parse and run as batch file
|
||||
var lines = programCode.split('\n').filter(function(it) { return it.length > 0 }) // this return is not shell's return!
|
||||
@@ -757,19 +843,28 @@ shell.execute = function(line) {
|
||||
// parse alias
|
||||
// $0: all arguments
|
||||
// $1..9: specific arguments
|
||||
// Tokens that contain whitespace or shell metacharacters must be re-quoted
|
||||
// before re-execution, otherwise the re-parse splits them on spaces.
|
||||
var quoteAliasArg = function(s) {
|
||||
if (s === undefined || s === null) return ""
|
||||
s = ''+s
|
||||
if (s.length === 0) return ""
|
||||
if (/[\s"|><&]/.test(s)) return '"' + s.replaceAll('"', '^"') + '"'
|
||||
return s
|
||||
}
|
||||
var lines = programCode.split('\n').filter(function(it) { return it.length > 0 }) // this return is not shell's return!
|
||||
lines.forEach(function(line) {
|
||||
var newLine = line
|
||||
|
||||
// replace $1..$9
|
||||
for (let j = 1; j < 9; j++) {
|
||||
newLine = newLine.replaceAll('$'+j, tokens[j])
|
||||
for (let j = 1; j <= 9; j++) {
|
||||
newLine = newLine.replaceAll('$'+j, quoteAliasArg(tokens[j]))
|
||||
}
|
||||
|
||||
// replace $0
|
||||
newLine = newLine.replaceAll('$0', tokens.slice(1).join(' '))
|
||||
newLine = newLine.replaceAll('$0', tokens.slice(1).map(quoteAliasArg).join(' '))
|
||||
|
||||
shell.execute(newLine)
|
||||
shell.execute(newLine, cmd)
|
||||
})
|
||||
}
|
||||
else if ("APP" == extension) {
|
||||
@@ -786,6 +881,10 @@ shell.execute = function(line) {
|
||||
errorlevel = 0 // reset the number
|
||||
|
||||
if (_G.shellProgramTitles === undefined) _G.shellProgramTitles = []
|
||||
if (nameOverride !== undefined) {
|
||||
tokens[0] = (''+nameOverride)
|
||||
cmd = tokens[0]
|
||||
}
|
||||
_G.shellProgramTitles.push(cmd.toUpperCase())
|
||||
sendLcdMsg(_G.shellProgramTitles[_G.shellProgramTitles.length - 1])
|
||||
//serial.println(_G.shellProgramTitles)
|
||||
@@ -825,6 +924,9 @@ shell.execute = function(line) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
shell.runningScriptPaths.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,6 +986,192 @@ Object.freeze(shell)
|
||||
_G.shell = shell
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// TAB AUTOCOMPLETION
|
||||
//
|
||||
// Invoked by TAB at the interactive prompt. Only active when BOTH:
|
||||
// 1. wintex.mjs is available (provides the selection popup), AND
|
||||
// 2. goFancy == true.
|
||||
// One candidate -> expand immediately (no popup).
|
||||
// Many candidates -> wintex popup; user scrolls and selects, or Esc/Cancel to
|
||||
// discard. The popup over-draws the screen without saving
|
||||
// what was beneath it, so we snapshot the text plane before
|
||||
// and copy it back after (the shell can't just redraw like a
|
||||
// full-screen TUI — there's scrollback above the prompt).
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Lazily-resolved wintex module. undefined = not probed yet, null = unavailable.
|
||||
let _acWin = undefined
|
||||
function getAutocompleteWin() {
|
||||
if (_acWin !== undefined) return _acWin
|
||||
_acWin = null
|
||||
try {
|
||||
let w = require("wintex") // resolved through INCLPATH (\tvdos\include\wintex.mjs)
|
||||
if (w && typeof w.showDialog === "function") _acWin = w
|
||||
} catch (e) {
|
||||
debugprintln("command.js > autocomplete: wintex unavailable: " + e)
|
||||
}
|
||||
return _acWin
|
||||
}
|
||||
|
||||
// List a directory's entries, swallowing any IO error.
|
||||
function _acListDir(fullPath) {
|
||||
try {
|
||||
let f = files.open(fullPath)
|
||||
if (!f.exists || !f.isDirectory) return []
|
||||
return f.list() || []
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
// Strip a trailing PATHEXT extension so command names show without ".js" etc.
|
||||
function _acStripExt(name) {
|
||||
let lower = name.toLowerCase()
|
||||
let exts = (_TVDOS.variables.PATHEXT || "").split(';').filter(function(e){ return e.length > 0 })
|
||||
for (let i = 0; i < exts.length; i++) {
|
||||
let e = exts[i].toLowerCase()
|
||||
if (lower.endsWith(e)) return name.substring(0, name.length - e.length)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// Candidates for the command position (first word, no path separators):
|
||||
// shell built-ins + runnable files found along the current dir, drive root and PATH.
|
||||
function _acCommandCandidates(prefix) {
|
||||
let lower = prefix.toLowerCase()
|
||||
let seen = {}
|
||||
let out = []
|
||||
function add(name) {
|
||||
let k = name.toLowerCase()
|
||||
if (seen[k]) return
|
||||
seen[k] = true
|
||||
out.push({ label: name, value: name + ' ', isDir: false })
|
||||
}
|
||||
|
||||
// shell built-ins (and their aliases)
|
||||
Object.keys(shell.coreutils).forEach(function(k) {
|
||||
if (k.toLowerCase().startsWith(lower)) add(k)
|
||||
})
|
||||
|
||||
// runnable files: search the same places shell.execute does, in the same order
|
||||
let exts = (_TVDOS.variables.PATHEXT || "").split(';')
|
||||
.filter(function(e){ return e.length > 0 }).map(function(e){ return e.toLowerCase() })
|
||||
let dirFulls = [shell.resolvePathInput('.').full] // current directory first
|
||||
_TVDOS.getPath().forEach(function(d) {
|
||||
dirFulls.push((d === '' || d === undefined) ? `${CURRENT_DRIVE}:\\` : shell.resolvePathInput(d).full)
|
||||
})
|
||||
dirFulls.forEach(function(full) {
|
||||
_acListDir(full).forEach(function(it) {
|
||||
if (it.isDirectory) return
|
||||
let nameLower = (it.name || '').toLowerCase()
|
||||
if (!exts.some(function(e){ return nameLower.endsWith(e) })) return // only runnables
|
||||
let stripped = _acStripExt(it.name)
|
||||
if (stripped.toLowerCase().startsWith(lower)) add(stripped)
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Candidates for a path argument. The word may carry a directory prefix
|
||||
// (kept verbatim) and a partial basename that we match against the directory.
|
||||
function _acPathCandidates(word) {
|
||||
let sepIdx = Math.max(word.lastIndexOf('\\'), word.lastIndexOf('/'))
|
||||
let dirPart, basePart, listArg
|
||||
if (sepIdx >= 0) {
|
||||
dirPart = word.substring(0, sepIdx + 1) // includes the trailing separator
|
||||
basePart = word.substring(sepIdx + 1)
|
||||
listArg = dirPart
|
||||
} else {
|
||||
dirPart = ''
|
||||
basePart = word
|
||||
listArg = '.'
|
||||
}
|
||||
let resolved = shell.resolvePathInput(listArg)
|
||||
if (resolved === undefined) return []
|
||||
let sep = (dirPart.length > 0 && dirPart.charAt(dirPart.length - 1) === '/') ? '/' : '\\'
|
||||
let lower = basePart.toLowerCase()
|
||||
let out = []
|
||||
_acListDir(resolved.full).forEach(function(it) {
|
||||
let name = it.name || ''
|
||||
if (!name.toLowerCase().startsWith(lower)) return
|
||||
out.push({
|
||||
// directories get a trailing separator so completion can continue into them;
|
||||
// files get a trailing space so the next argument can be typed straight away.
|
||||
label: name + (it.isDirectory ? '\\' : ''),
|
||||
value: dirPart + name + (it.isDirectory ? sep : ' '),
|
||||
isDir: it.isDirectory
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Work out what is being completed at `caret` within `line`.
|
||||
// Returns { wordStart, word, candidates } (candidates sorted by label).
|
||||
function computeCompletion(line, caret) {
|
||||
let wordStart = caret
|
||||
while (wordStart > 0 && line.charAt(wordStart - 1) !== ' ') wordStart -= 1
|
||||
let word = line.substring(wordStart, caret)
|
||||
let isFirstWord = (line.substring(0, wordStart).trim().length === 0)
|
||||
let hasPathSep = (word.indexOf('\\') >= 0 || word.indexOf('/') >= 0 || word.indexOf(':') >= 0)
|
||||
let candidates = (isFirstWord && !hasPathSep) ? _acCommandCandidates(word) : _acPathCandidates(word)
|
||||
candidates.sort(function(a, b) { return (a.label < b.label) ? -1 : (a.label > b.label) ? 1 : 0 })
|
||||
return { wordStart: wordStart, word: word, candidates: candidates }
|
||||
}
|
||||
|
||||
// --- text-plane snapshot/restore (so the popup leaves no artefacts) ---------
|
||||
// In a vtmgr pane the shimmed con/print draw into the pane buffer
|
||||
// (globalThis.VT_TEXT_PLANE, forward layout); on the physical console they
|
||||
// draw into the GPU text area (mapped at getGpuMemBase()-253950). vaddr(0) is
|
||||
// that base in either case; sys.memcpy reads/writes it forward-native.
|
||||
// NOTE: 7681, not the full 7682-byte text area: relPtrInDev() bounds-checks
|
||||
// `from+len` inclusively, so the final byte (bottom-right char cell, never
|
||||
// touched by a centred popup) is unreachable by a single memcpy.
|
||||
const _AC_TEXTAREA_BYTES = 7681
|
||||
let _acTextBase = null
|
||||
let _acScratchPtr = 0
|
||||
function _acTextAreaBase() {
|
||||
if (_acTextBase === null) {
|
||||
_acTextBase = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||
? globalThis.VT_TEXT_PLANE
|
||||
: (graphics.getGpuMemBase() - 253950)
|
||||
}
|
||||
return _acTextBase
|
||||
}
|
||||
function _acSnapshotScreen() {
|
||||
if (_acScratchPtr === 0) _acScratchPtr = sys.malloc(_AC_TEXTAREA_BYTES)
|
||||
sys.memcpy(_acTextAreaBase(), _acScratchPtr, _AC_TEXTAREA_BYTES)
|
||||
}
|
||||
function _acRestoreScreen() {
|
||||
if (_acScratchPtr === 0) return
|
||||
sys.memcpy(_acScratchPtr, _acTextAreaBase(), _AC_TEXTAREA_BYTES)
|
||||
}
|
||||
|
||||
// Modal popup of candidates. Returns the chosen item, or null if discarded.
|
||||
function _acShowPopup(win, candidates) {
|
||||
let res = win.showDialog({
|
||||
title: `Complete (${candidates.length})`,
|
||||
list: {
|
||||
items: candidates,
|
||||
height: Math.min(12, candidates.length),
|
||||
onActivate: function(item, idx, key) { return 'select' }
|
||||
},
|
||||
buttons: [{ label: 'Cancel', action: 'cancel' }]
|
||||
})
|
||||
if (res && res.action === 'select' && res.listItem) return res.listItem
|
||||
return null
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// ensure USERCONFIGPATH directory exists
|
||||
try {
|
||||
let userConfigPath = `${CURRENT_DRIVE}:${_TVDOS.variables.USERCONFIGPATH}`
|
||||
let userConfigDir = files.open(userConfigPath)
|
||||
if (!userConfigDir.exists) {
|
||||
debugprintln(`command.js > creating USERCONFIGPATH at ${userConfigPath}`)
|
||||
userConfigDir.mkDir()
|
||||
}
|
||||
} catch (e) {
|
||||
debugprintln("command.js > USERCONFIGPATH creation failed: " + e.message)
|
||||
}
|
||||
|
||||
if (exec_args[1] !== undefined) {
|
||||
// only meaningful switches would be either -c or -k anyway
|
||||
@@ -928,23 +1216,133 @@ if (goInteractive) {
|
||||
print_prompt_text()
|
||||
|
||||
var cmdbuf = ""
|
||||
var caret = 0 // insertion point within cmdbuf, 0..cmdbuf.length
|
||||
|
||||
// Self-contained line editor with a movable caret (so command.js does
|
||||
// NOT depend on wintex being installed). The prompt has just been
|
||||
// printed, so the current cursor marks where the editable text begins.
|
||||
// We track that anchor and rebuild the on-screen line from it, decoding
|
||||
// line-wrap ourselves so the maths holds in both the physical console
|
||||
// and a vtmgr pane (whose con.move CLAMPS x instead of wrapping it).
|
||||
let [baseY, baseX] = con.getyx() // 1-based
|
||||
let termCols = con.getmaxyx()[1]
|
||||
|
||||
// absolute (y,x) on screen for caret index `idx`
|
||||
function caretPos(idx) {
|
||||
let abs = (baseX - 1) + idx
|
||||
return [baseY + ((abs / termCols) | 0), (abs % termCols) + 1]
|
||||
}
|
||||
function gotoCaret() {
|
||||
let [cy, cx] = caretPos(caret)
|
||||
con.move(cy, cx)
|
||||
}
|
||||
// reprint cmdbuf from index `from` to the end, optionally padding with
|
||||
// `clearTrail` blanks to wipe characters left over by a now-shorter
|
||||
// line, then park the hardware cursor back on the caret.
|
||||
function refresh(from, clearTrail) {
|
||||
let [py, px] = caretPos(from)
|
||||
con.move(py, px)
|
||||
print(cmdbuf.substring(from))
|
||||
for (let i = 0; i < clearTrail; i++) print(" ")
|
||||
gotoCaret()
|
||||
}
|
||||
// replace the whole buffer (used by history recall)
|
||||
function setBuf(next) {
|
||||
let oldLen = cmdbuf.length
|
||||
cmdbuf = next
|
||||
caret = cmdbuf.length
|
||||
refresh(0, Math.max(0, oldLen - cmdbuf.length))
|
||||
}
|
||||
|
||||
// Replace the word [wordStart, caret) with `value`, keeping any text to
|
||||
// the right of the caret, then reprint the line from `wordStart`.
|
||||
function applyCompletion(wordStart, value) {
|
||||
let oldLen = cmdbuf.length
|
||||
cmdbuf = cmdbuf.substring(0, wordStart) + value + cmdbuf.substring(caret)
|
||||
caret = wordStart + value.length
|
||||
con.color_pair(shell.usrcfg.textCol, 255)
|
||||
refresh(wordStart, Math.max(0, oldLen - cmdbuf.length))
|
||||
}
|
||||
|
||||
// TAB handler. No-op unless fancy mode is on and wintex is installed.
|
||||
function tryAutocomplete() {
|
||||
if (!goFancy) return
|
||||
let win = getAutocompleteWin()
|
||||
if (!win) return
|
||||
|
||||
let comp = computeCompletion(cmdbuf, caret)
|
||||
let cands = comp.candidates
|
||||
if (cands.length === 0) return
|
||||
if (cands.length === 1) { applyCompletion(comp.wordStart, cands[0].value); return }
|
||||
|
||||
_acSnapshotScreen()
|
||||
let chosen = _acShowPopup(win, cands)
|
||||
_acRestoreScreen()
|
||||
|
||||
// The popup drives input through input.withEvent (physical held-key
|
||||
// state), which bypasses the buffer con.getch reads. Inside a vtmgr
|
||||
// pane the dispatcher keeps draining physical keystrokes into this
|
||||
// pane's input ring the whole time the popup is open, so the navigation
|
||||
// keys (and the closing Enter) would otherwise surface as phantom input
|
||||
// afterwards. Flush them. (On the physical console readKey self-clears,
|
||||
// so this is harmless there.)
|
||||
con.resetkeybuf()
|
||||
|
||||
// The popup hid the caret and clobbered colours; restore the prompt
|
||||
// editing state. The screen content is already back from the snapshot.
|
||||
con.curs_set(1)
|
||||
con.color_pair(shell.usrcfg.textCol, 255)
|
||||
gotoCaret()
|
||||
|
||||
if (chosen) applyCompletion(comp.wordStart, chosen.value)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
let key = con.getch()
|
||||
|
||||
// printable chars
|
||||
if (key >= 32 && key <= 126) {
|
||||
var s = String.fromCharCode(key)
|
||||
cmdbuf += s
|
||||
print(s)
|
||||
let s = String.fromCharCode(key)
|
||||
let atEnd = (caret === cmdbuf.length)
|
||||
cmdbuf = cmdbuf.substring(0, caret) + s + cmdbuf.substring(caret)
|
||||
caret += 1
|
||||
if (atEnd) print(s) // fast path: simple append
|
||||
else refresh(caret - 1, 0)
|
||||
}
|
||||
// backspace
|
||||
else if (key === con.KEY_BACKSPACE && cmdbuf.length > 0) {
|
||||
cmdbuf = cmdbuf.substring(0, cmdbuf.length - 1)
|
||||
print(String.fromCharCode(key))
|
||||
// TAB: autocomplete (fancy mode + wintex only; otherwise a no-op)
|
||||
else if (key === con.KEY_TAB) {
|
||||
tryAutocomplete()
|
||||
}
|
||||
// backspace: delete the char to the left of the caret
|
||||
else if (key === con.KEY_BACKSPACE && caret > 0) {
|
||||
cmdbuf = cmdbuf.substring(0, caret - 1) + cmdbuf.substring(caret)
|
||||
caret -= 1
|
||||
refresh(caret, 1)
|
||||
}
|
||||
// forward delete: delete the char under the caret
|
||||
else if (key === con.KEY_DELETE && caret < cmdbuf.length) {
|
||||
cmdbuf = cmdbuf.substring(0, caret) + cmdbuf.substring(caret + 1)
|
||||
refresh(caret, 1)
|
||||
}
|
||||
// caret left
|
||||
else if (key === con.KEY_LEFT) {
|
||||
if (caret > 0) { caret -= 1; gotoCaret() }
|
||||
}
|
||||
// caret right
|
||||
else if (key === con.KEY_RIGHT) {
|
||||
if (caret < cmdbuf.length) { caret += 1; gotoCaret() }
|
||||
}
|
||||
// jump to start of line
|
||||
else if (key === con.KEY_HOME) {
|
||||
caret = 0; gotoCaret()
|
||||
}
|
||||
// jump to end of line
|
||||
else if (key === con.KEY_END) {
|
||||
caret = cmdbuf.length; gotoCaret()
|
||||
}
|
||||
// enter
|
||||
else if (key === 10 || key === con.KEY_RETURN) {
|
||||
caret = cmdbuf.length; gotoCaret()
|
||||
println()
|
||||
|
||||
errorlevel = shell.execute(cmdbuf)
|
||||
@@ -960,32 +1358,17 @@ if (goInteractive) {
|
||||
// up arrow
|
||||
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
|
||||
cmdHistoryScroll += 1
|
||||
|
||||
// back the cursor in order to type new cmd
|
||||
var x = 0
|
||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
||||
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
|
||||
// re-type the new command
|
||||
print(cmdbuf)
|
||||
|
||||
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||
}
|
||||
// down arrow
|
||||
else if (key === con.KEY_DOWN) {
|
||||
if (cmdHistoryScroll > 0) {
|
||||
// back the cursor in order to type new cmd
|
||||
var x = 0
|
||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
||||
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
|
||||
// re-type the new command
|
||||
print(cmdbuf)
|
||||
|
||||
if (cmdHistoryScroll > 1) {
|
||||
cmdHistoryScroll -= 1
|
||||
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||
}
|
||||
else {
|
||||
// back the cursor in order to type new cmd
|
||||
var x = 0
|
||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
||||
cmdbuf = ""
|
||||
else if (cmdHistoryScroll === 1) {
|
||||
cmdHistoryScroll = 0
|
||||
setBuf("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
assets/disk0/tvdos/bin/hop.alias
Normal file
1
assets/disk0/tvdos/bin/hop.alias
Normal file
@@ -0,0 +1 @@
|
||||
hopper $0
|
||||
@@ -1,5 +1,956 @@
|
||||
/**
|
||||
* Hopper is a package manager for TSVM
|
||||
* Hopper is a package manager for TVDOS
|
||||
* Created by CuriousTorvald on 2026-04-16
|
||||
*/
|
||||
|
||||
const SYSTEM_PACKEAGE_DEF_DIR = "A:/tvdos/hopper"
|
||||
const USER_BASE_DIR = "A:/hopper"
|
||||
const USER_PACKAGE_DEF_DIR = `${USER_BASE_DIR}/manifests`
|
||||
const USER_PACKAGE_BIN_DIR = `${USER_BASE_DIR}/bin`
|
||||
const USER_PACKAGE_INCLUDE_DIR = `${USER_BASE_DIR}/include`
|
||||
const MANIFEST_EXT = "hop.per"
|
||||
const MIRROR_LIST_PATH = `${SYSTEM_PACKEAGE_DEF_DIR}/mirrors.list`
|
||||
|
||||
const net = require("net")
|
||||
|
||||
// SYNOPSIS
|
||||
// hopper {search,se} [--provides, --requires, --description, --author] query
|
||||
//// default searches from ProperName
|
||||
// hopper {install,in} query [-v version]
|
||||
// hopper {remove,rm} query
|
||||
|
||||
// ============================================================
|
||||
// Manifest parsing
|
||||
// ============================================================
|
||||
|
||||
function splitList(s) {
|
||||
if (!s) return []
|
||||
return s.split(";").map(it => it.trim()).filter(it => it.length > 0)
|
||||
}
|
||||
|
||||
function parseManifest(text) {
|
||||
const m = {}
|
||||
text.split("\n").forEach(rawLine => {
|
||||
const line = rawLine.replace(/\r$/, "")
|
||||
if (line.length === 0) return
|
||||
const idx = line.indexOf(":")
|
||||
if (idx < 0) return
|
||||
const key = line.substring(0, idx).trim()
|
||||
const value = line.substring(idx + 1).trim()
|
||||
m[key] = value
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
function readManifestFile(path) {
|
||||
const f = files.open(path)
|
||||
if (!f.exists || f.isDirectory) return undefined
|
||||
const m = parseManifest(f.sread())
|
||||
m._manifestPath = path
|
||||
return m
|
||||
}
|
||||
|
||||
function _listManifestsFrom(dirPath, origin) {
|
||||
const dir = files.open(dirPath)
|
||||
if (!dir.exists || !dir.isDirectory) return []
|
||||
const out = []
|
||||
dir.list().forEach(entry => {
|
||||
if (entry.isDirectory) return
|
||||
if (!entry.name.toLowerCase().endsWith(MANIFEST_EXT)) return
|
||||
const m = readManifestFile(entry.fullPath)
|
||||
if (m !== undefined) {
|
||||
m._origin = origin
|
||||
out.push(m)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// System packages (shipped with TVDOS) live in SYSTEM_PACKAGE_DEF_DIR
|
||||
// and are read-only as far as hopper is concerned. User packages,
|
||||
// installed by `hopper install`, live under USER_PACKAGE_DEF_DIR. The
|
||||
// resolver treats both as "installed", but the install/remove paths
|
||||
// refuse to modify anything tagged `_origin === "system"`.
|
||||
function listInstalledManifests() {
|
||||
return _listManifestsFrom(SYSTEM_PACKEAGE_DEF_DIR, "system")
|
||||
.concat(_listManifestsFrom(USER_PACKAGE_DEF_DIR, "user"))
|
||||
}
|
||||
|
||||
function findInstalledManifest(name) {
|
||||
// Prefer user-installed copy when a system package with the same name
|
||||
// also exists -- but that combination is normally refused at install.
|
||||
const userDirect = `${USER_PACKAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
|
||||
let m = readManifestFile(userDirect)
|
||||
if (m !== undefined) { m._origin = "user"; return m }
|
||||
|
||||
const sysDirect = `${SYSTEM_PACKEAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
|
||||
m = readManifestFile(sysDirect)
|
||||
if (m !== undefined) { m._origin = "system"; return m }
|
||||
|
||||
const all = listInstalledManifests()
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
if ((all[i].HopperPackageName || "") === name) return all[i]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Yes/no prompt. Empty input falls back to `defaultYes`.
|
||||
function confirm(prompt, defaultYes) {
|
||||
const hint = defaultYes ? "[Y/n]" : "[y/N]"
|
||||
print(`${prompt} ${hint} `)
|
||||
const ans = (read() || "").trim().toLowerCase()
|
||||
if (ans === "") return !!defaultYes
|
||||
return ans === "y" || ans === "yes"
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Install layout helpers
|
||||
// ============================================================
|
||||
//
|
||||
// User-installed packages live under `A:/hopper/`. Files are routed
|
||||
// by extension: `.mjs` includes go under `include/`, everything else
|
||||
// (`.js`, `.alias`, `.lfs`, data blobs, ...) lands in `bin/`. The
|
||||
// downloaded manifest is saved under `manifests/` with a
|
||||
// `SystemPackagePath` field appended that lists the resulting paths.
|
||||
|
||||
// Strip query/fragment and take the last `/`-separated component of `url`.
|
||||
function urlBasename(url) {
|
||||
let s = String(url || "")
|
||||
const qm = s.indexOf("?"); if (qm >= 0) s = s.substring(0, qm)
|
||||
const hash = s.indexOf("#"); if (hash >= 0) s = s.substring(0, hash)
|
||||
const slash = s.lastIndexOf("/")
|
||||
return (slash < 0) ? s : s.substring(slash + 1)
|
||||
}
|
||||
|
||||
function routeForBasename(name) {
|
||||
return (String(name || "").toLowerCase().endsWith(".mjs"))
|
||||
? USER_PACKAGE_INCLUDE_DIR
|
||||
: USER_PACKAGE_BIN_DIR
|
||||
}
|
||||
|
||||
// Convert a USER_BASE_DIR-relative absolute path ("A:/hopper/bin/foo.js")
|
||||
// into its declarable form ("/hopper/bin/foo.js"), matching the
|
||||
// `SystemPackagePath` convention used by the system manifests.
|
||||
function declarablePath(absPath) {
|
||||
let p = String(absPath || "").replace(/\\/g, "/")
|
||||
if (/^[A-Za-z]:/.test(p)) p = p.substring(2)
|
||||
return p
|
||||
}
|
||||
|
||||
// Parse PackageFileList (semicolon-separated full URLs) into a list of
|
||||
// download descriptors: { url, basename, localPath }.
|
||||
function parsePackageFileList(s) {
|
||||
const out = []
|
||||
splitList(s || "").forEach(url => {
|
||||
const base = urlBasename(url)
|
||||
if (base.length === 0) return
|
||||
const dir = routeForBasename(base)
|
||||
out.push({ url: url, basename: base, localPath: `${dir}/${base}` })
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function ensureUserDirs() {
|
||||
[USER_BASE_DIR, USER_PACKAGE_BIN_DIR, USER_PACKAGE_INCLUDE_DIR, USER_PACKAGE_DEF_DIR].forEach(p => {
|
||||
const d = files.open(p)
|
||||
if (!d.exists) d.mkDir()
|
||||
})
|
||||
}
|
||||
|
||||
// Re-emit a parsed manifest, preserving insertion order, dropping
|
||||
// internal `_*` keys, and replacing any pre-existing SystemPackagePath
|
||||
// with the locally-computed one so the field always reflects what is
|
||||
// actually on disk.
|
||||
function serializeManifest(manifestObj, installedPathStr) {
|
||||
const lines = []
|
||||
Object.keys(manifestObj).forEach(k => {
|
||||
if (k.length > 0 && k[0] === "_") return
|
||||
if (k === "SystemPackagePath") return
|
||||
lines.push(`${k}:${manifestObj[k]}`)
|
||||
})
|
||||
lines.push(`SystemPackagePath:${installedPathStr}`)
|
||||
return lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
// Delete every file declared in `manifest.SystemPackagePath` plus the
|
||||
// manifest file itself. Wildcards are expanded via `expandSystemPath`.
|
||||
function deleteInstalledFiles(manifest) {
|
||||
const removed = []
|
||||
splitList(manifest.SystemPackagePath || "").forEach(p => {
|
||||
expandSystemPath(p).forEach(abs => {
|
||||
const fd = files.open(abs)
|
||||
if (!fd.exists) return
|
||||
try { fd.remove(); removed.push(abs) }
|
||||
catch (e) { printerrln(` ! failed to remove ${abs}: ${e}`) }
|
||||
})
|
||||
})
|
||||
if (manifest._manifestPath) {
|
||||
const mfd = files.open(manifest._manifestPath)
|
||||
if (mfd.exists) {
|
||||
try { mfd.remove(); removed.push(manifest._manifestPath) }
|
||||
catch (e) { printerrln(` ! failed to remove ${manifest._manifestPath}: ${e}`) }
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SemVer (strict X.Y.Z) and constraint matching
|
||||
// ============================================================
|
||||
//
|
||||
// Versions are strict Semantic Versioning: three non-negative integer
|
||||
// components MAJOR.MINOR.PATCH. No pre-release / build metadata.
|
||||
//
|
||||
// Constraint grammar (intentionally small, expandable later):
|
||||
// * any version
|
||||
// X.* major X, any minor/patch
|
||||
// X.Y.* major X, minor Y, any patch
|
||||
// X.Y.Z exact
|
||||
// ^X.Y.Z >= X.Y.Z and < (X+1).0.0 (major-compatible)
|
||||
// ~X.Y.Z >= X.Y.Z and < X.(Y+1).0 (minor-compatible)
|
||||
// >=X.Y.Z / >X.Y.Z / <=X.Y.Z / <X.Y.Z / =X.Y.Z
|
||||
//
|
||||
// Multiple comma-separated constraints are AND-ed: "^1.2.0,<1.5.0".
|
||||
|
||||
function parseVersion(v) {
|
||||
const m = String(v || "0.0.0").trim().match(/^(\d+)\.(\d+)\.(\d+)$/)
|
||||
if (!m) return [0, 0, 0]
|
||||
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
|
||||
}
|
||||
|
||||
function compareVersion(a, b) {
|
||||
const A = parseVersion(a), B = parseVersion(b)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (A[i] !== B[i]) return (A[i] < B[i]) ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function _matchSingleConstraint(version, c) {
|
||||
c = c.trim()
|
||||
if (c === "" || c === "*") return true
|
||||
|
||||
// Operator form: ^, ~, >=, <=, >, <, =
|
||||
let opMatch = c.match(/^(\^|~|>=|<=|>|<|=)\s*(\d+\.\d+\.\d+)$/)
|
||||
if (opMatch) {
|
||||
const op = opMatch[1]
|
||||
const target = opMatch[2]
|
||||
const cmp = compareVersion(version, target)
|
||||
const [tM, tm] = parseVersion(target)
|
||||
switch (op) {
|
||||
case "=": return cmp === 0
|
||||
case ">": return cmp > 0
|
||||
case ">=": return cmp >= 0
|
||||
case "<": return cmp < 0
|
||||
case "<=": return cmp <= 0
|
||||
case "^": return cmp >= 0 && compareVersion(version, `${tM + 1}.0.0`) < 0
|
||||
case "~": return cmp >= 0 && compareVersion(version, `${tM}.${tm + 1}.0`) < 0
|
||||
}
|
||||
}
|
||||
|
||||
// Wildcard form: X.*, X.Y.*, X.x, X.Y.x, or exact X.Y.Z
|
||||
const parts = c.split(".")
|
||||
const vparts = parseVersion(version)
|
||||
for (let i = 0; i < parts.length && i < 3; i++) {
|
||||
if (parts[i] === "*" || parts[i] === "x" || parts[i] === "X") return true
|
||||
const expected = parseInt(parts[i], 10)
|
||||
if (isNaN(expected) || vparts[i] !== expected) return false
|
||||
}
|
||||
// All listed parts matched literally; remaining parts (if any) must be 0
|
||||
for (let i = parts.length; i < 3; i++) {
|
||||
if (vparts[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function satisfies(version, constraint) {
|
||||
if (!constraint) return true
|
||||
return constraint.split(",").every(c => _matchSingleConstraint(version, c))
|
||||
}
|
||||
|
||||
function parseRequires(s) {
|
||||
const out = []
|
||||
splitList(s || "").forEach(entry => {
|
||||
// "<name>" or "<name> <constraint>"
|
||||
const idx = entry.search(/\s+/)
|
||||
if (idx < 0) {
|
||||
out.push({ name: entry, constraint: "*" })
|
||||
} else {
|
||||
out.push({ name: entry.substring(0, idx), constraint: entry.substring(idx + 1).trim() })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// HopperProvides entries are "<name>" or "<name> <version>". A bare name
|
||||
// falls back to the package's own HopperPackageVersion — the same idea
|
||||
// as RPM's `Provides: aalib = 1.2.0` (where the package's real name and
|
||||
// version may differ from the virtual identity it exposes).
|
||||
function parseProvides(s, fallbackVersion) {
|
||||
const out = []
|
||||
splitList(s || "").forEach(entry => {
|
||||
const idx = entry.search(/\s+/)
|
||||
if (idx < 0) {
|
||||
out.push({ name: entry, version: fallbackVersion })
|
||||
} else {
|
||||
const v = entry.substring(idx + 1).trim()
|
||||
out.push({ name: entry.substring(0, idx), version: v || fallbackVersion })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Look up the version a candidate exposes for `name`. If `name` matches
|
||||
// the package's own name (or isn't declared in HopperProvides at all),
|
||||
// returns the package's own version.
|
||||
function providedVersionOf(candidate, name) {
|
||||
if (candidate.provides) {
|
||||
for (let i = 0; i < candidate.provides.length; i++) {
|
||||
if (candidate.provides[i].name === name) return candidate.provides[i].version
|
||||
}
|
||||
}
|
||||
return candidate.version
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Candidate index (installed + upstream)
|
||||
// ============================================================
|
||||
|
||||
function _manifestToCandidate(m, source) {
|
||||
const name = m.HopperPackageName || ""
|
||||
const version = m.HopperPackageVersion || "0.0.0"
|
||||
const provides = parseProvides(m.HopperProvides || "", version)
|
||||
// Every package implicitly provides itself at its own version. Only
|
||||
// synthesise this when the manifest didn't declare it explicitly.
|
||||
if (name && !provides.some(p => p.name === name)) {
|
||||
provides.unshift({ name: name, version: version })
|
||||
}
|
||||
return {
|
||||
name: name,
|
||||
version: version,
|
||||
requires: parseRequires(m.HopperRequires || ""),
|
||||
provides: provides,
|
||||
source: source, // "installed" | "upstream"
|
||||
manifest: m
|
||||
}
|
||||
}
|
||||
|
||||
// Returns map: packageName -> array<Candidate>
|
||||
function buildCandidateIndex() {
|
||||
const idx = new Map()
|
||||
function add(c) {
|
||||
if (!idx.has(c.name)) idx.set(c.name, [])
|
||||
// De-dupe (name+version+source)
|
||||
const arr = idx.get(c.name)
|
||||
if (arr.some(x => x.version === c.version && x.source === c.source)) return
|
||||
arr.push(c)
|
||||
}
|
||||
|
||||
listInstalledManifests().forEach(m => add(_manifestToCandidate(m, "installed")))
|
||||
fetchRemoteCandidates().forEach(m => add(_manifestToCandidate(m, "upstream")))
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
// Anything that satisfies a requirement on `name`: a package whose own
|
||||
// HopperPackageName matches OR whose HopperProvides declares `name`.
|
||||
// Each candidate now carries `provides` as {name, version} pairs; the
|
||||
// package's own (name, version) is always present (see
|
||||
// _manifestToCandidate), so a single pass over `provides` is enough.
|
||||
function findProviders(idx, name) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
idx.forEach(candidates => {
|
||||
candidates.forEach(c => {
|
||||
if (seen.has(c)) return
|
||||
if (c.provides.some(p => p.name === name)) {
|
||||
out.push(c)
|
||||
seen.add(c)
|
||||
}
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Sort: installed first (no churn), then highest version, then upstream order.
|
||||
function sortCandidates(cands) {
|
||||
return cands.slice().sort((a, b) => {
|
||||
if (a.source !== b.source) return (a.source === "installed") ? -1 : 1
|
||||
return -compareVersion(a.version, b.version)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
|
||||
// ============================================================
|
||||
//
|
||||
// State: chosen :: Map<packageName, Candidate>
|
||||
// At every choice point we snapshot the whole map so that backtracking
|
||||
// also undoes any transitive picks. The candidate ordering encodes the
|
||||
// preference policy:
|
||||
//
|
||||
// 1. Keep installed if it satisfies the constraint.
|
||||
// 2. Otherwise pick the newest upstream version that satisfies.
|
||||
// 3. If newer versions cause downstream conflicts, walk older versions
|
||||
// (downgrade) until either something fits or candidates are exhausted.
|
||||
//
|
||||
// The structure is intentionally close to DPLL: each "decision" is the
|
||||
// candidate we assign to a variable, and "unit propagation" is the
|
||||
// recursive resolve() call over each requirement. Replacing this with
|
||||
// clause learning / a watched-literals scheme later would be local.
|
||||
|
||||
function resolveAll(idx, requirements) {
|
||||
const chosen = new Map()
|
||||
const issues = []
|
||||
|
||||
function snapshot() { return new Map(chosen) }
|
||||
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
|
||||
|
||||
function _resolve(reqName, constraint, trail) {
|
||||
const existing = chosen.get(reqName)
|
||||
if (existing !== undefined) {
|
||||
const v = providedVersionOf(existing, reqName)
|
||||
return satisfies(v, constraint)
|
||||
? { ok: true }
|
||||
: { ok: false, reason: `${reqName} pinned to ${v}, but ${trail.join(" -> ")} requires ${constraint}` }
|
||||
}
|
||||
|
||||
const providers = findProviders(idx, reqName)
|
||||
if (providers.length === 0) {
|
||||
return { ok: false, reason: `no package provides "${reqName}" (required by ${trail.join(" -> ") || "<root>"})` }
|
||||
}
|
||||
// Satisfaction checks the virtual version the candidate exposes
|
||||
// for `reqName` (HopperProvides), not necessarily the package's
|
||||
// own HopperPackageVersion.
|
||||
const matching = sortCandidates(providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint)))
|
||||
if (matching.length === 0) {
|
||||
const versions = providers.map(p => `${providedVersionOf(p, reqName)}[${p.source}]`).join(", ")
|
||||
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
|
||||
}
|
||||
|
||||
let lastReason = null
|
||||
for (let i = 0; i < matching.length; i++) {
|
||||
const cand = matching[i]
|
||||
const snap = snapshot()
|
||||
chosen.set(cand.name, cand)
|
||||
|
||||
let allOk = true
|
||||
const subTrail = trail.concat([`${cand.name}@${cand.version}`])
|
||||
for (let j = 0; j < cand.requires.length; j++) {
|
||||
const req = cand.requires[j]
|
||||
const r = _resolve(req.name, req.constraint, subTrail)
|
||||
if (!r.ok) {
|
||||
allOk = false
|
||||
lastReason = r.reason
|
||||
break
|
||||
}
|
||||
}
|
||||
if (allOk) return { ok: true }
|
||||
restore(snap)
|
||||
}
|
||||
|
||||
return { ok: false, reason: lastReason || `no working candidate for "${reqName}"` }
|
||||
}
|
||||
|
||||
requirements.forEach(req => {
|
||||
const r = _resolve(req.name, req.constraint, [])
|
||||
if (!r.ok) issues.push(r.reason)
|
||||
})
|
||||
|
||||
return { chosen, issues }
|
||||
}
|
||||
|
||||
// Compare resolved assignment against currently-installed state.
|
||||
function classifyPlan(idx, chosen) {
|
||||
const installedByName = new Map()
|
||||
listInstalledManifests().forEach(m => installedByName.set(m.HopperPackageName, m))
|
||||
|
||||
const actions = []
|
||||
chosen.forEach((cand, name) => {
|
||||
const inst = installedByName.get(name)
|
||||
if (cand.source === "installed") {
|
||||
actions.push({ action: "keep", name, version: cand.version })
|
||||
}
|
||||
else if (inst === undefined) {
|
||||
actions.push({ action: "install", name, version: cand.version })
|
||||
}
|
||||
else {
|
||||
const cmp = compareVersion(cand.version, inst.HopperPackageVersion)
|
||||
if (cmp > 0) actions.push({ action: "upgrade", name, from: inst.HopperPackageVersion, to: cand.version })
|
||||
else if (cmp < 0) actions.push({ action: "downgrade", name, from: inst.HopperPackageVersion, to: cand.version })
|
||||
else actions.push({ action: "reinstall", name, version: cand.version })
|
||||
}
|
||||
})
|
||||
return actions
|
||||
}
|
||||
|
||||
function printPlan(actions, target) {
|
||||
const changing = actions.filter(a => a.action !== "keep")
|
||||
if (changing.length === 0) {
|
||||
println(`Nothing to do: ${target} is already installed and satisfied.`)
|
||||
return
|
||||
}
|
||||
println("Plan:")
|
||||
changing.forEach(a => {
|
||||
switch (a.action) {
|
||||
case "install": println(` + install ${a.name} ${a.version}`); break
|
||||
case "upgrade": println(` ^ upgrade ${a.name} ${a.from} -> ${a.to}`); break
|
||||
case "downgrade": println(` v downgrade ${a.name} ${a.from} -> ${a.to}`); break
|
||||
case "reinstall": println(` = reinstall ${a.name} ${a.version}`); break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Remote mirrors
|
||||
// ============================================================
|
||||
//
|
||||
// `mirrors.list` lives next to the installed package manifests.
|
||||
// Each non-empty, non-`#` line is the URL prefix of a Hopper mirror.
|
||||
// The mirror MUST expose `<prefix>mirror_manifest` (key:value pairs
|
||||
// describing the mirror) and `<prefix>filelist` (CSV with rows of
|
||||
// `packagename,version,hoppermanifest-filename`).
|
||||
//
|
||||
// Trailing slash on the prefix is optional and will be added if missing.
|
||||
|
||||
function loadMirrorList() {
|
||||
const f = files.open(MIRROR_LIST_PATH)
|
||||
if (!f.exists || f.isDirectory) return []
|
||||
return f.sread().split("\n")
|
||||
.map(line => line.replace(/\r$/, "").trim())
|
||||
.filter(line => line.length > 0 && line[0] !== "#")
|
||||
.map(line => line.endsWith("/") ? line : (line + "/"))
|
||||
}
|
||||
|
||||
function parseFileList(text) {
|
||||
const out = []
|
||||
text.split("\n").forEach(raw => {
|
||||
const line = raw.replace(/\r$/, "").trim()
|
||||
if (line.length === 0 || line[0] === "#") return
|
||||
const parts = line.split(",")
|
||||
if (parts.length < 3) return
|
||||
out.push({
|
||||
name: parts[0].trim(),
|
||||
version: parts[1].trim(),
|
||||
file: parts[2].trim(),
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function fetchManifestsFromMirror(prefix) {
|
||||
const mfText = net.fetchText(prefix + "mirror_manifest")
|
||||
if (mfText === null) {
|
||||
printerrln(` ! could not reach mirror: ${prefix}`)
|
||||
return []
|
||||
}
|
||||
const mirror = parseManifest(mfText)
|
||||
const mirrorName = mirror.HopperMirrorName || prefix
|
||||
|
||||
const flText = net.fetchText(prefix + "filelist")
|
||||
if (flText === null) {
|
||||
printerrln(` ! mirror "${mirrorName}" has no filelist`)
|
||||
return []
|
||||
}
|
||||
|
||||
const out = []
|
||||
parseFileList(flText).forEach(entry => {
|
||||
const manifestText = net.fetchText(prefix + entry.file)
|
||||
if (manifestText === null) {
|
||||
printerrln(` ! mirror "${mirrorName}" missing ${entry.file}`)
|
||||
return
|
||||
}
|
||||
const m = parseManifest(manifestText)
|
||||
m._mirrorName = mirrorName
|
||||
m._mirrorPrefix = prefix
|
||||
m._manifestUrl = prefix + entry.file
|
||||
out.push(m)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Per-invocation memoisation. Search and install both pull the same
|
||||
// data; we only want to hit the network once per `hopper ...` call.
|
||||
let _remoteCache = null
|
||||
|
||||
function fetchRemoteCandidates() {
|
||||
if (_remoteCache !== null) return _remoteCache
|
||||
|
||||
const mirrors = loadMirrorList()
|
||||
if (mirrors.length === 0) {
|
||||
_remoteCache = []
|
||||
return _remoteCache
|
||||
}
|
||||
|
||||
if (!net.isAvailable()) {
|
||||
printerrln("Warning: no HTTP modem attached; remote mirrors will be skipped.")
|
||||
_remoteCache = []
|
||||
return _remoteCache
|
||||
}
|
||||
|
||||
const out = []
|
||||
mirrors.forEach(prefix => {
|
||||
fetchManifestsFromMirror(prefix).forEach(m => out.push(m))
|
||||
})
|
||||
_remoteCache = out
|
||||
return _remoteCache
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Search
|
||||
// ============================================================
|
||||
|
||||
function fieldCandidates(manifest, field) {
|
||||
switch (field) {
|
||||
case "provides": return splitList(manifest.HopperProvides || "")
|
||||
case "requires": return splitList(manifest.HopperRequires || "")
|
||||
case "description": return [manifest.ProperDescription || ""]
|
||||
case "author": return [manifest.ProperAuthor || ""]
|
||||
default: return [manifest.ProperName || "", manifest.HopperPackageName || ""]
|
||||
}
|
||||
}
|
||||
|
||||
function matchesQuery(manifest, field, query) {
|
||||
const q = query.toLowerCase()
|
||||
return fieldCandidates(manifest, field).some(c => c.toLowerCase().indexOf(q) >= 0)
|
||||
}
|
||||
|
||||
function printSearchResult(m, origin) {
|
||||
const name = m.ProperName || m.HopperPackageName || "(unnamed)"
|
||||
const ver = m.HopperPackageVersion || "?"
|
||||
println(` [${origin}] ${name} -- ${m.HopperPackageName} ${ver}`)
|
||||
if (m.ProperDescription) println(` ${m.ProperDescription}`)
|
||||
}
|
||||
|
||||
function cmdSearch(args) {
|
||||
let field = "name"
|
||||
let query = undefined
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i]
|
||||
if (a === "--provides") field = "provides"
|
||||
else if (a === "--requires") field = "requires"
|
||||
else if (a === "--description") field = "description"
|
||||
else if (a === "--author") field = "author"
|
||||
else if (a.startsWith("--")) { printerrln(`Unknown option: ${a}`); return 1 }
|
||||
else query = a
|
||||
}
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper search [--provides|--requires|--description|--author] <query>")
|
||||
return 1
|
||||
}
|
||||
|
||||
println(`Searching installed packages in ${SYSTEM_PACKEAGE_DEF_DIR} ...`)
|
||||
const sysHits = listInstalledManifests().filter(m => matchesQuery(m, field, query))
|
||||
if (sysHits.length === 0) println(" (no matches)")
|
||||
else sysHits.forEach(m => printSearchResult(m, "installed"))
|
||||
|
||||
println("")
|
||||
println("Searching remote mirrors ...")
|
||||
const remote = fetchRemoteCandidates()
|
||||
if (remote.length === 0) {
|
||||
println(" (no mirrors configured or reachable)")
|
||||
}
|
||||
else {
|
||||
const netHits = remote.filter(m => matchesQuery(m, field, query))
|
||||
if (netHits.length === 0) println(" (no matches)")
|
||||
else netHits.forEach(m => printSearchResult(m, m._mirrorName || "remote"))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Install
|
||||
// ============================================================
|
||||
//
|
||||
// Each upstream manifest declares its payload via `PackageFileList`,
|
||||
// a semicolon-separated list of full URLs. Hopper fetches each URL and
|
||||
// drops the result in /hopper/bin (default) or /hopper/include (.mjs).
|
||||
// The locally-saved manifest gets a `SystemPackagePath` field appended
|
||||
// listing the resulting absolute paths, which is what `cmdRemove` later
|
||||
// walks to clean up.
|
||||
|
||||
function _installOne(action, candidate) {
|
||||
const m = candidate.manifest
|
||||
const files_ = parsePackageFileList(m.PackageFileList)
|
||||
if (files_.length === 0) {
|
||||
printerrln(` ! ${candidate.name}: upstream manifest has no PackageFileList; cannot install`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Fetch first, write second: a single 404 should not leave a
|
||||
// half-installed package behind.
|
||||
const fetched = []
|
||||
for (let i = 0; i < files_.length; i++) {
|
||||
const f = files_[i]
|
||||
println(` fetch ${f.url}`)
|
||||
const body = net.fetchText(f.url)
|
||||
if (body === null || body === undefined) {
|
||||
printerrln(` ! failed to fetch ${f.url}`)
|
||||
return false
|
||||
}
|
||||
fetched.push({ entry: f, body: body })
|
||||
}
|
||||
|
||||
// If we are replacing an existing user-installed copy, remove its
|
||||
// old files first so a renamed payload doesn't leave orphans.
|
||||
if (action !== "install") {
|
||||
const oldManifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
|
||||
const old = readManifestFile(oldManifestPath)
|
||||
if (old !== undefined) {
|
||||
splitList(old.SystemPackagePath || "").forEach(p => {
|
||||
expandSystemPath(p).forEach(abs => {
|
||||
const fd = files.open(abs)
|
||||
if (fd.exists) {
|
||||
try { fd.remove() }
|
||||
catch (e) { printerrln(` ! could not remove old ${abs}: ${e}`) }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Write payload files.
|
||||
fetched.forEach(item => {
|
||||
const fd = files.open(item.entry.localPath)
|
||||
if (!fd.exists) fd.mkFile()
|
||||
fd.swrite(item.body)
|
||||
println(` write ${item.entry.localPath}`)
|
||||
})
|
||||
|
||||
// Save the manifest with SystemPackagePath appended.
|
||||
const sysPath = fetched.map(item => declarablePath(item.entry.localPath)).join(";")
|
||||
const manifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
|
||||
const mfd = files.open(manifestPath)
|
||||
if (!mfd.exists) mfd.mkFile()
|
||||
mfd.swrite(serializeManifest(m, sysPath))
|
||||
println(` write ${manifestPath}`)
|
||||
return true
|
||||
}
|
||||
|
||||
function cmdInstall(args) {
|
||||
let query = undefined
|
||||
let version = undefined
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "-v") { version = args[i + 1]; i++ }
|
||||
else if (args[i].startsWith("--")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
|
||||
else query = args[i]
|
||||
}
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper install <package> [-v <version>]")
|
||||
return 1
|
||||
}
|
||||
|
||||
const targetConstraint = version || "*"
|
||||
const verSuffix = (targetConstraint !== "*") ? ` (${targetConstraint})` : ""
|
||||
println(`Resolving ${query}${verSuffix} ...`)
|
||||
|
||||
const idx = buildCandidateIndex()
|
||||
|
||||
// Sanity check: target must exist in the index (installed or upstream).
|
||||
if (findProviders(idx, query).length === 0) {
|
||||
printerrln(`Error: package "${query}" not found (not on upstream, not installed).`)
|
||||
return 4
|
||||
}
|
||||
|
||||
// Seed order matters: the target goes FIRST so its (possibly tight)
|
||||
// constraints can drive upgrades of dependencies. The installed-set
|
||||
// requirements follow at "*" so the resolver still has to keep them
|
||||
// alive (preferring installed candidates when their version still fits,
|
||||
// otherwise upgrading or downgrading them).
|
||||
const seed = [{ name: query, constraint: targetConstraint }]
|
||||
listInstalledManifests().forEach(m => {
|
||||
if (m.HopperPackageName === query) return
|
||||
seed.push({ name: m.HopperPackageName, constraint: "*" })
|
||||
})
|
||||
|
||||
const { chosen, issues } = resolveAll(idx, seed)
|
||||
if (issues.length > 0) {
|
||||
printerrln("Resolution failed:")
|
||||
issues.forEach(reason => printerrln(` - ${reason}`))
|
||||
printerrln("")
|
||||
printerrln("No solution found -- not installable.")
|
||||
return 3
|
||||
}
|
||||
|
||||
const plan = classifyPlan(idx, chosen)
|
||||
printPlan(plan, query)
|
||||
|
||||
const changing = plan.filter(a => a.action !== "keep")
|
||||
if (changing.length === 0) return 0
|
||||
|
||||
// Pre-flight: refuse to clobber system packages, and require every
|
||||
// upstream candidate to actually carry a payload list.
|
||||
const blockers = []
|
||||
changing.forEach(a => {
|
||||
const cand = chosen.get(a.name)
|
||||
const inst = findInstalledManifest(a.name)
|
||||
if (inst && inst._origin === "system") {
|
||||
blockers.push(`${a.name}: cannot ${a.action} -- a system package with that name is already installed`)
|
||||
}
|
||||
if (cand && cand.source === "upstream" && !(cand.manifest.PackageFileList && cand.manifest.PackageFileList.length > 0)) {
|
||||
blockers.push(`${a.name}: upstream manifest declares no PackageFileList`)
|
||||
}
|
||||
})
|
||||
if (blockers.length > 0) {
|
||||
printerrln("Cannot proceed:")
|
||||
blockers.forEach(b => printerrln(` - ${b}`))
|
||||
return 5
|
||||
}
|
||||
|
||||
if (!net.isAvailable()) {
|
||||
printerrln("No HTTP modem attached; cannot fetch package files.")
|
||||
return 6
|
||||
}
|
||||
|
||||
println("")
|
||||
if (!confirm("Proceed with installation?", true)) {
|
||||
println("Aborted.")
|
||||
return 0
|
||||
}
|
||||
|
||||
ensureUserDirs()
|
||||
|
||||
let failed = 0
|
||||
for (let i = 0; i < changing.length; i++) {
|
||||
const a = changing[i]
|
||||
const cand = chosen.get(a.name)
|
||||
if (a.action === "install" || a.action === "reinstall") {
|
||||
println(`${a.action} ${a.name} ${a.version}`)
|
||||
} else {
|
||||
println(`${a.action} ${a.name} ${a.from} -> ${a.to}`)
|
||||
}
|
||||
if (!_installOne(a.action, cand)) {
|
||||
failed++
|
||||
printerrln(` ! ${a.name}: aborted`)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
printerrln(`${failed} package(s) failed to install.`)
|
||||
return 7
|
||||
}
|
||||
|
||||
println("Done.")
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Remove
|
||||
// ============================================================
|
||||
|
||||
// Convert a SystemPackagePath entry (e.g. "/tvdos/bin/taut*") into a
|
||||
// concrete list of files on the A: drive. Supports a simple '*' wildcard
|
||||
// in the filename component.
|
||||
function expandSystemPath(pattern) {
|
||||
const sysDrive = "A:"
|
||||
|
||||
if (pattern.indexOf("*") < 0) {
|
||||
return [`${sysDrive}${pattern}`]
|
||||
}
|
||||
|
||||
const fwd = pattern.lastIndexOf("/")
|
||||
const bck = pattern.lastIndexOf("\\")
|
||||
const lastSep = Math.max(fwd, bck)
|
||||
const dirPart = (lastSep < 0) ? "" : pattern.substring(0, lastSep)
|
||||
const namePart = (lastSep < 0) ? pattern : pattern.substring(lastSep + 1)
|
||||
|
||||
const dir = files.open(`${sysDrive}${dirPart}/`)
|
||||
if (!dir.exists || !dir.isDirectory) return []
|
||||
|
||||
const escaped = namePart.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")
|
||||
const re = new RegExp(`^${escaped}$`, "i")
|
||||
|
||||
const out = []
|
||||
dir.list().forEach(entry => {
|
||||
if (entry.isDirectory) return
|
||||
if (re.test(entry.name)) out.push(entry.fullPath)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function cmdRemove(args) {
|
||||
const query = args[0]
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper remove <package>")
|
||||
return 1
|
||||
}
|
||||
|
||||
const m = findInstalledManifest(query)
|
||||
if (m === undefined) {
|
||||
printerrln(`Package not installed: ${query}`)
|
||||
return 2
|
||||
}
|
||||
if (m._origin === "system") {
|
||||
printerrln(`Cannot remove ${query}: it is a system package.`)
|
||||
return 6
|
||||
}
|
||||
|
||||
const name = m.ProperName || m.HopperPackageName || query
|
||||
const ver = m.HopperPackageVersion || "?"
|
||||
println(`Preparing removal of ${name} (${m.HopperPackageName} ${ver}) ...`)
|
||||
|
||||
const paths = splitList(m.SystemPackagePath || "")
|
||||
println("")
|
||||
println("The following files will be deleted:")
|
||||
if (paths.length === 0) {
|
||||
println(" (manifest declares no files)")
|
||||
}
|
||||
paths.forEach(p => {
|
||||
const expanded = expandSystemPath(p)
|
||||
if (expanded.length === 0) {
|
||||
println(` (no match on disk) ${p}`)
|
||||
}
|
||||
else {
|
||||
expanded.forEach(e => println(` ${e}`))
|
||||
}
|
||||
})
|
||||
println(` ${m._manifestPath}`)
|
||||
|
||||
println("")
|
||||
if (!confirm("Proceed with removal?", false)) {
|
||||
println("Aborted.")
|
||||
return 0
|
||||
}
|
||||
|
||||
const removed = deleteInstalledFiles(m)
|
||||
removed.forEach(p => println(` removed ${p}`))
|
||||
if (removed.length === 0) println(" (nothing was removed)")
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dispatch
|
||||
// ============================================================
|
||||
|
||||
function printUsage() {
|
||||
println("Hopper - Package manager for TVDOS")
|
||||
println("")
|
||||
println("Usage:")
|
||||
println(" hopper {search,se} [--provides|--requires|--description|--author] <query>")
|
||||
println(" hopper {install,in} <package> [-v <version>]")
|
||||
println(" hopper {remove,rm} <package>")
|
||||
}
|
||||
|
||||
const _hopperArgs = (typeof exec_args !== "undefined" && exec_args) ? exec_args.slice(1) : []
|
||||
const _hopperCmd = _hopperArgs[0]
|
||||
const _hopperRest = _hopperArgs.slice(1)
|
||||
|
||||
switch (_hopperCmd) {
|
||||
case "search":
|
||||
case "se":
|
||||
return cmdSearch(_hopperRest)
|
||||
case "install":
|
||||
case "in":
|
||||
return cmdInstall(_hopperRest)
|
||||
case "remove":
|
||||
case "rm":
|
||||
return cmdRemove(_hopperRest)
|
||||
case undefined:
|
||||
printUsage()
|
||||
return 0
|
||||
default:
|
||||
printerrln(`Unknown command: ${_hopperCmd}`)
|
||||
printUsage()
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ Uint16 Encoding
|
||||
10 00 : UTF-8
|
||||
10 01 : UTF-16BE
|
||||
10 02 : UTF-16LE
|
||||
Byte[5] Padding
|
||||
Byte Flags
|
||||
0b 0000 000r
|
||||
r: path is relative
|
||||
Bytes[4] Reserved
|
||||
|
||||
# FileBlocks
|
||||
Uint8 File type (only 1 is used)
|
||||
@@ -28,27 +31,36 @@ instead of compressing individual files)
|
||||
|
||||
function printUsage() {
|
||||
println(`Collects files under a directory into a single archive.
|
||||
Usage: lfs [-c/-x/-t] dest.lfs path\\to\\source
|
||||
Usage: lfs [-c/-x/-t] [-r] dest.lfs path\\to\\source
|
||||
To collect a directory into myarchive.lfs:
|
||||
lfs -c myarchive.lfs path\\to\\directory
|
||||
To collect a directory into myarchive.lfs, using relative path:
|
||||
lfs -c -r myarchive.lfs path\\to\\directory
|
||||
To extract an archive to path\\to\\my\\files:
|
||||
lfs -x myarchive.lfs path\\to\\my\\files
|
||||
To list the collected files:
|
||||
lfs -t myarchive.lfs`)
|
||||
}
|
||||
|
||||
let option = exec_args[1]
|
||||
const lfsPath = exec_args[2]
|
||||
const dirPath = exec_args[3]
|
||||
let option = undefined
|
||||
let useRelative = false
|
||||
const positional = []
|
||||
for (let i = 1; i < exec_args.length; i++) {
|
||||
const a = exec_args[i]
|
||||
if (a === undefined) continue
|
||||
const au = a.toUpperCase()
|
||||
if (au === "-C" || au === "-X" || au === "-T") option = au
|
||||
else if (au === "-R") useRelative = true
|
||||
else positional.push(a)
|
||||
}
|
||||
const lfsPath = positional[0]
|
||||
const dirPath = positional[1]
|
||||
|
||||
|
||||
if (option === undefined || lfsPath === undefined || option.toUpperCase() != "-T" && dirPath === undefined) {
|
||||
if (option === undefined || lfsPath === undefined || (option != "-T" && dirPath === undefined)) {
|
||||
printUsage()
|
||||
return 0
|
||||
}
|
||||
|
||||
option = option.toUpperCase()
|
||||
|
||||
|
||||
function recurseDir(file, action) {
|
||||
if (!file.isDirectory) {
|
||||
@@ -76,13 +88,14 @@ if ("-C" == option) {
|
||||
return 1
|
||||
}
|
||||
|
||||
let out = "TVDOSLFS\x01\x00\x00\x00\x00\x00\x00\x00"
|
||||
const flagsByte = useRelative ? 0x01 : 0x00
|
||||
let out = "TVDOSLFS\x01\x00\x00" + String.fromCharCode(flagsByte) + "\x00\x00\x00\x00"
|
||||
const rootDirPathLen = rootDir.fullPath.length
|
||||
|
||||
recurseDir(rootDir, file=>{
|
||||
let f = files.open(file.fullPath)
|
||||
let flen = f.size
|
||||
let fname = file.fullPath.substring(rootDirPathLen + 1)
|
||||
let fname = useRelative ? file.fullPath.substring(rootDirPathLen + 1) : file.fullPath
|
||||
let plen = fname.length
|
||||
|
||||
out += "\x01" + String.fromCharCode(
|
||||
@@ -116,6 +129,8 @@ else if ("-T" == option || "-X" == option) {
|
||||
return 2
|
||||
}
|
||||
|
||||
const archiveRelative = (bytes.charCodeAt(11) & 0x01) !== 0
|
||||
|
||||
if ("-X" == option && !rootDir.exists) {
|
||||
rootDir.mkDir()
|
||||
}
|
||||
@@ -132,9 +147,12 @@ else if ("-T" == option || "-X" == option) {
|
||||
|
||||
if ("-X" == option) {
|
||||
let filebytes = bytes.substring(curs, curs + filelen)
|
||||
let outfile = files.open(`${rootDir.fullPath}\\${path}`)
|
||||
// Fully qualified paths (e.g. "A:\foo\bar.txt") get their drive prefix
|
||||
// stripped so the archive contents re-root under the destination dir.
|
||||
let subPath = archiveRelative ? path : path.replace(/^[A-Za-z]:[\\\/]?/, "")
|
||||
let outfile = files.open(`${rootDir.fullPath}\\${subPath}`)
|
||||
|
||||
mkDirs(files.open(`${rootDir.driveLetter}:${files.open(`${rootDir.fullPath}\\${path}`).parentPath}`))
|
||||
mkDirs(files.open(`${outfile.driveLetter}:${outfile.parentPath}`))
|
||||
outfile.mkFile()
|
||||
outfile.swrite(filebytes)
|
||||
}
|
||||
|
||||
@@ -1,209 +1,122 @@
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
// playmp2 — MPEG-1/2 Audio Layer II player with the shared playgui visualiser.
|
||||
// Usage: playmp2 <file.mp2> [-i]
|
||||
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
if (!SND_BASE_ADDR) return 10
|
||||
|
||||
const MP2_BITRATES = ["???", 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]
|
||||
const MP2_BITRATES = ["???", 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]
|
||||
const MP2_CHANNELMODES = ["Stereo", "Joint", "Dual", "Mono"]
|
||||
|
||||
const pcm = require("pcm")
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
|
||||
|
||||
class SequentialFileBuffer {
|
||||
|
||||
constructor(path, offset, length) {
|
||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||
|
||||
this.path = path
|
||||
this.file = files.open(path)
|
||||
|
||||
this.offset = offset || 0
|
||||
this.originalOffset = offset
|
||||
this.length = length || this.file.size
|
||||
|
||||
this.seq = require("seqread")
|
||||
this.seq.prepare(path)
|
||||
}
|
||||
|
||||
readBytes(size, ptr) {
|
||||
return this.seq.readBytes(size, ptr)
|
||||
}
|
||||
|
||||
readStr(n) {
|
||||
let ptr = this.seq.readBytes(n)
|
||||
let s = ''
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i >= this.length) break
|
||||
s += String.fromCharCode(sys.peek(ptr + i))
|
||||
}
|
||||
sys.free(ptr)
|
||||
return s
|
||||
}
|
||||
|
||||
unread(diff) {
|
||||
let newSkipLen = this.seq.getReadCount() - diff
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(newSkipLen)
|
||||
}
|
||||
|
||||
rewind() {
|
||||
this.seq.prepare(this.path)
|
||||
}
|
||||
|
||||
seek(p) {
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(p)
|
||||
}
|
||||
|
||||
get byteLength() {
|
||||
return this.length
|
||||
}
|
||||
|
||||
get fileHeader() {
|
||||
return this.seq.fileHeader
|
||||
}
|
||||
|
||||
/*get remaining() {
|
||||
return this.length - this.getReadCount()
|
||||
}*/
|
||||
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||
get fileHeader() { return this.seq.fileHeader }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const FILE_SIZE = filebuf.length// - 100
|
||||
const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader)
|
||||
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const FILE_SIZE = filebuf.length
|
||||
const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader)
|
||||
const MEDIA_BITRATE = MP2_BITRATES[filebuf.fileHeader[2] >>> 4]
|
||||
const MEDIA_CHANNEL_MODE = MP2_CHANNELMODES[filebuf.fileHeader[3] >>> 6]
|
||||
const MEDIA_CHANNEL = MP2_CHANNELMODES[filebuf.fileHeader[3] >>> 6]
|
||||
|
||||
// mediaDecodedBin sits at MMIO offset 64 in the audio peripheral and holds
|
||||
// 2304 bytes (1152 stereo u8 samples per MP2 frame). Peripheral memory grows
|
||||
// toward 0 so the canonical pointer is SND_BASE_ADDR - 64.
|
||||
//
|
||||
// IMPORTANT: single-byte sys.peek on this address hits AudioAdapter.peek()
|
||||
// which maps the lower offsets to sampleBin, not mediaDecodedBin (the
|
||||
// MMIO/Memory-Space split — see CLAUDE.md). To get the decoded PCM into the
|
||||
// visualiser, we sys.memcpy mediaDecodedBin → a RAM scratch buffer; memcpy
|
||||
// uses VM.getDev internally which DOES route the MMIO read correctly.
|
||||
//
|
||||
// VM.getDev's range check on mediaDecodedBin (relPtrInDev) is half-open and
|
||||
// won't let us copy the full 2304 bytes — we copy 2302 (one stereo sample
|
||||
// short of the frame, invisible at visualiser resolution).
|
||||
const MP2_DECODED_ADDR = SND_BASE_ADDR - 64
|
||||
const MP2_VIS_COPY_BYTES = 2302
|
||||
const MP2_VIS_SAMPLE_COUNT = MP2_VIS_COPY_BYTES >> 1 // 1151
|
||||
const mp2VisScratch = interactive ? sys.malloc(MP2_VIS_COPY_BYTES) : 0
|
||||
|
||||
let bytes_left = FILE_SIZE
|
||||
let bytes_left = FILE_SIZE
|
||||
let decodedLength = 0
|
||||
|
||||
|
||||
//serial.println(`Frame size: ${FRAME_SIZE}`)
|
||||
|
||||
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(filebuf.file.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - filebuf.file.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `MP2 ${MEDIA_CHANNEL_MODE} ${MEDIA_BITRATE}kbps`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
let [cy, cx] = con.getyx()
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
function bytesToSec(i) {
|
||||
// using fixed value: FRAME_SIZE(216) bytes for 36 ms on sampling rate 32000 Hz
|
||||
return i / (FRAME_SIZE * 1000 / bufRealTimeLen)
|
||||
}
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
function printPlayBar(currently) {
|
||||
if (interactive) {
|
||||
let currently = decodedLength
|
||||
let total = FILE_SIZE
|
||||
|
||||
let currentlySec = Math.round(bytesToSec(currently))
|
||||
let totalSec = Math.round(bytesToSec(total))
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
||||
audio.setPcmQueueCapacityIndex(0, 2)
|
||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
audio.play(0)
|
||||
|
||||
|
||||
//let mp2context = audio.mp2Init()
|
||||
audio.mp2Init()
|
||||
|
||||
// decode frame
|
||||
let t1 = sys.nanoTime()
|
||||
let bufRealTimeLen = 36
|
||||
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
||||
|
||||
if (interactive) {
|
||||
const tag = "MP2"
|
||||
const title = `${filebuf.file.name} ${MEDIA_CHANNEL} ${MEDIA_BITRATE}kbps`
|
||||
gui.audioInit({ title, tag })
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
let errorlevel = 0
|
||||
try {
|
||||
while (bytes_left > 0 && !stopPlay) {
|
||||
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) {
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
|
||||
printPlayBar()
|
||||
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
filebuf.readBytes(FRAME_SIZE, SND_BASE_ADDR - 2368)
|
||||
audio.mp2Decode()
|
||||
|
||||
// After decode, 1152 PCMu8 stereo samples sit in mediaDecodedBin
|
||||
// (MMIO). Bounce them through RAM so single-byte peek in the
|
||||
// visualiser pipeline can reach them — see MP2_DECODED_ADDR notes.
|
||||
if (interactive) {
|
||||
sys.memcpy(MP2_DECODED_ADDR, mp2VisScratch, MP2_VIS_COPY_BYTES)
|
||||
gui.audioFeedPcm(mp2VisScratch, MP2_VIS_SAMPLE_COUNT)
|
||||
}
|
||||
|
||||
if (audio.getPosition(0) >= QUEUE_MAX) {
|
||||
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
||||
printdbg(`Queue full, waiting until the queue has some space (${audio.getPosition(0)}/${QUEUE_MAX})`)
|
||||
if (interactive) gui.audioRender()
|
||||
sys.sleep(bufRealTimeLen)
|
||||
}
|
||||
}
|
||||
audio.mp2UploadDecoded(0)
|
||||
|
||||
if (interactive) {
|
||||
gui.audioSetProgress(decodedLength / FILE_SIZE,
|
||||
bytesToSec(decodedLength), bytesToSec(FILE_SIZE))
|
||||
gui.audioRender()
|
||||
}
|
||||
sys.sleep(10)
|
||||
|
||||
|
||||
|
||||
bytes_left -= FRAME_SIZE
|
||||
bytes_left -= FRAME_SIZE
|
||||
decodedLength += FRAME_SIZE
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
if (interactive) {
|
||||
if (mp2VisScratch) sys.free(mp2VisScratch)
|
||||
gui.audioClose()
|
||||
}
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
@@ -1,196 +1,81 @@
|
||||
// usage: playpcm audiofile.pcm [/i]
|
||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
let filename = fileeeee.fullPath
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
// playpcm — raw PCMu8 stereo player with the shared playgui visualiser.
|
||||
// Usage: playpcm <file.pcm> [-i]
|
||||
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const pcm = require("pcm")
|
||||
const FILE_SIZE = files.open(filename).size
|
||||
|
||||
|
||||
|
||||
function printComments() {
|
||||
for (const [key, value] of Object.entries(comments)) {
|
||||
printdbg(`${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function GCD(a, b) {
|
||||
a = Math.abs(a)
|
||||
b = Math.abs(b)
|
||||
if (b > a) {var temp = a; a = b; b = temp}
|
||||
while (true) {
|
||||
if (b == 0) return a
|
||||
a %= b
|
||||
if (a == 0) return b
|
||||
b %= a
|
||||
}
|
||||
}
|
||||
|
||||
function LCM(a, b) {
|
||||
return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b))
|
||||
}
|
||||
|
||||
|
||||
|
||||
//println("Reading...")
|
||||
//serial.println("!!! READING")
|
||||
const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const filePath = fileHandle.fullPath
|
||||
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
const pcm = require("pcm")
|
||||
const seqread = require("seqread")
|
||||
seqread.prepare(filename)
|
||||
|
||||
|
||||
|
||||
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
const FILE_SIZE = files.open(filePath).size
|
||||
|
||||
let BLOCK_SIZE = 4096
|
||||
let INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
const QUEUE_MAX = 8 // according to the spec
|
||||
const INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
const QUEUE_MAX = 8
|
||||
|
||||
let nChannels = 2
|
||||
let samplingRate = pcm.HW_SAMPLING_RATE;
|
||||
let blockSize = 2;
|
||||
let bitsPerSample = 8;
|
||||
let byterate = 2*samplingRate;
|
||||
let comments = {};
|
||||
let readPtr = undefined
|
||||
let decodePtr = undefined
|
||||
const samplingRate = pcm.HW_SAMPLING_RATE
|
||||
const byterate = 2 * samplingRate
|
||||
|
||||
function bytesToSec(i) {
|
||||
return i / byterate
|
||||
}
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(fileeeee.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - fileeeee.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `Raw PCM 512kbps`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
let [cy, cx] = con.getyx()
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
// read chunks loop
|
||||
readPtr = sys.malloc(BLOCK_SIZE * bitsPerSample / 8)
|
||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||
function bytesToSec(i) { return i / byterate }
|
||||
|
||||
seqread.prepare(filePath)
|
||||
|
||||
const readPtr = sys.malloc(BLOCK_SIZE)
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
|
||||
let readLength = 1
|
||||
|
||||
function printPlayBar() {
|
||||
if (interactive) {
|
||||
let currently = seqread.getReadCount()
|
||||
let total = FILE_SIZE
|
||||
|
||||
let currentlySec = Math.round(bytesToSec(currently))
|
||||
let totalSec = Math.round(bytesToSec(total))
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
if (interactive) {
|
||||
gui.audioInit({
|
||||
title: `${fileHandle.name} Raw PCM 32kHz Stereo`,
|
||||
tag: "PCM"
|
||||
})
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
let errorlevel = 0
|
||||
let readLength = 1
|
||||
try {
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) {
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
const queueSize = audio.getPosition(0)
|
||||
if (queueSize <= 1) {
|
||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||
const remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) break
|
||||
|
||||
let queueSize = audio.getPosition(0)
|
||||
if (queueSize <= 1) {
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
|
||||
printPlayBar()
|
||||
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
||||
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
||||
|
||||
// upload four samples for lag-safely
|
||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
||||
audio.setSampleUploadLength(0, readLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) {
|
||||
printdbg(`readLength = ${readLength}`)
|
||||
break
|
||||
if (repeat > 1) sys.sleep(10)
|
||||
}
|
||||
|
||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE}; readLength: ${readLength}`)
|
||||
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
|
||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
||||
audio.setSampleUploadLength(0, readLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
|
||||
if (repeat > 1) sys.sleep(10)
|
||||
|
||||
printPlayBar()
|
||||
audio.play(0)
|
||||
}
|
||||
|
||||
audio.play(0)
|
||||
if (interactive) {
|
||||
const cur = seqread.getReadCount()
|
||||
gui.audioSetProgress(cur / FILE_SIZE, bytesToSec(cur), bytesToSec(FILE_SIZE))
|
||||
gui.audioRender()
|
||||
}
|
||||
sys.sleep(10)
|
||||
}
|
||||
|
||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||
printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()};`)
|
||||
|
||||
|
||||
sys.sleep(10)
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
//audio.stop(0)
|
||||
} finally {
|
||||
if (readPtr !== undefined) sys.free(readPtr)
|
||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
||||
if (interactive) gui.audioClose()
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
|
||||
|
||||
@@ -1,112 +1,66 @@
|
||||
// playtad — TAD (TSVM Advanced Audio) player with the shared playgui visualiser.
|
||||
// Usage: playtad <file.tad> [-i | -d]
|
||||
// -i Interactive mode (visualiser + progress bar; hold Backspace to exit)
|
||||
// -d Dump mode (print the first three chunks to serial for debugging)
|
||||
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
const SND_MEM_ADDR = audio.getMemAddr()
|
||||
const TAD_INPUT_ADDR = SND_MEM_ADDR - 262144 // TAD input buffer (matches TAV packet 0x24)
|
||||
const TAD_DECODED_ADDR = SND_MEM_ADDR - 262144 + 65536 // TAD decoded buffer
|
||||
const SND_MEM_ADDR = audio.getMemAddr()
|
||||
// tadInputBin at offset 917504, tadDecodedBin at 983040. Both addressed via
|
||||
// negative pointers — peripheral memory grows toward 0.
|
||||
const TAD_INPUT_ADDR = SND_MEM_ADDR - 917504
|
||||
const TAD_DECODED_ADDR = SND_MEM_ADDR - 983040
|
||||
|
||||
if (!SND_BASE_ADDR) return 10
|
||||
|
||||
// Check for help flag or missing arguments
|
||||
if (!exec_args[1] || exec_args[1] == "-h" || exec_args[1] == "--help") {
|
||||
serial.println("Usage: playtad <file.tad> [-i | -d] [quality]")
|
||||
serial.println(" -i Interactive mode (progress bar, press Backspace to exit)")
|
||||
serial.println(" -d Dump mode (show first 3 chunks with payload hex and decoded samples)")
|
||||
serial.println("")
|
||||
serial.println("Examples:")
|
||||
serial.println(" playtad audio.tad -i # Play with progress bar")
|
||||
serial.println(" playtad audio.tad -d # Dump first 3 chunks for debugging")
|
||||
if (!exec_args[1] || exec_args[1] === "-h" || exec_args[1] === "--help") {
|
||||
serial.println("Usage: playtad <file.tad> [-i | -d]")
|
||||
serial.println(" -i Interactive mode (visualiser + progress bar)")
|
||||
serial.println(" -d Dump first three chunks for debugging")
|
||||
return 0
|
||||
}
|
||||
|
||||
const pcm = require("pcm")
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() == "-d"
|
||||
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() === "-d"
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
class SequentialFileBuffer {
|
||||
|
||||
constructor(path, offset, length) {
|
||||
constructor(path) {
|
||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||
|
||||
this.path = path
|
||||
this.file = files.open(path)
|
||||
|
||||
this.offset = offset || 0
|
||||
this.originalOffset = offset
|
||||
this.length = length || this.file.size
|
||||
|
||||
this.length = this.file.size
|
||||
this.seq = require("seqread")
|
||||
this.seq.prepare(path)
|
||||
}
|
||||
|
||||
readBytes(size, ptr) {
|
||||
return this.seq.readBytes(size, ptr)
|
||||
}
|
||||
|
||||
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||
readByte() {
|
||||
let ptr = this.seq.readBytes(1)
|
||||
let val = sys.peek(ptr)
|
||||
const ptr = this.seq.readBytes(1)
|
||||
const val = sys.peek(ptr)
|
||||
sys.free(ptr)
|
||||
return val
|
||||
}
|
||||
|
||||
readShort() {
|
||||
let ptr = this.seq.readBytes(2)
|
||||
let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
||||
const ptr = this.seq.readBytes(2)
|
||||
const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
||||
sys.free(ptr)
|
||||
return val
|
||||
}
|
||||
|
||||
readInt() {
|
||||
let ptr = this.seq.readBytes(4)
|
||||
let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24)
|
||||
const ptr = this.seq.readBytes(4)
|
||||
const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24)
|
||||
sys.free(ptr)
|
||||
return val
|
||||
}
|
||||
|
||||
readStr(n) {
|
||||
let ptr = this.seq.readBytes(n)
|
||||
let s = ''
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i >= this.length) break
|
||||
s += String.fromCharCode(sys.peek(ptr + i))
|
||||
}
|
||||
sys.free(ptr)
|
||||
return s
|
||||
}
|
||||
|
||||
unread(diff) {
|
||||
let newSkipLen = this.seq.getReadCount() - diff
|
||||
const newSkipLen = this.seq.getReadCount() - diff
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(newSkipLen)
|
||||
}
|
||||
|
||||
rewind() {
|
||||
this.seq.prepare(this.path)
|
||||
}
|
||||
|
||||
seek(p) {
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(p)
|
||||
}
|
||||
|
||||
get byteLength() {
|
||||
return this.length
|
||||
}
|
||||
|
||||
get fileHeader() {
|
||||
return this.seq.fileHeader
|
||||
}
|
||||
|
||||
getReadCount() {
|
||||
return this.seq.getReadCount()
|
||||
}
|
||||
rewind() { this.seq.prepare(this.path) }
|
||||
getReadCount() { return this.seq.getReadCount() }
|
||||
}
|
||||
|
||||
|
||||
// Read TAD chunk header to determine format
|
||||
let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const FILE_SIZE = filebuf.length
|
||||
|
||||
if (FILE_SIZE < 7) {
|
||||
@@ -114,12 +68,12 @@ if (FILE_SIZE < 7) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Read first chunk header (standalone TAD format: no TAV wrapper)
|
||||
let firstSampleCount = filebuf.readShort()
|
||||
let firstMaxIndex = filebuf.readByte()
|
||||
let firstPayloadSize = filebuf.readInt()
|
||||
// Peek the first chunk header so we know the chunk size for the rough bytes-
|
||||
// to-seconds conversion shown in the progress bar.
|
||||
const firstSampleCount = filebuf.readShort()
|
||||
const firstMaxIndex = filebuf.readByte()
|
||||
const firstPayloadSize = filebuf.readInt()
|
||||
|
||||
// Validate first chunk
|
||||
if (firstSampleCount < 0 || firstSampleCount > 65536) {
|
||||
serial.println(`ERROR: Invalid sample count ${firstSampleCount}. File may be corrupted.`)
|
||||
return 1
|
||||
@@ -133,148 +87,68 @@ if (firstPayloadSize < 1 || firstPayloadSize > 65536) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Rewind to start
|
||||
filebuf.rewind()
|
||||
|
||||
// Calculate approximate frame info
|
||||
const AVG_CHUNK_SIZE = 7 + firstPayloadSize // TAD header (2+1+4) + payload
|
||||
const SAMPLE_RATE = 32000
|
||||
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000) // milliseconds per chunk
|
||||
const AVG_CHUNK_SIZE = 7 + firstPayloadSize
|
||||
const SAMPLE_RATE = 32000
|
||||
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000)
|
||||
|
||||
if (dumpCoeffs) {
|
||||
serial.println(`TAD Coefficient Dump Mode`)
|
||||
serial.println(`File: ${filebuf.file.name}`)
|
||||
serial.println(`First chunk header:`)
|
||||
serial.println(` Sample Count: ${firstSampleCount}`)
|
||||
serial.println(` Max Index: ${firstMaxIndex}`)
|
||||
serial.println(` Payload Size: ${firstPayloadSize} bytes`)
|
||||
serial.println(`First chunk: ${firstSampleCount} samples, Q${firstMaxIndex}, ${firstPayloadSize} bytes payload`)
|
||||
serial.println(`Chunk Duration: ${bufRealTimeLen} ms`)
|
||||
serial.println(``)
|
||||
}
|
||||
|
||||
|
||||
let bytes_left = FILE_SIZE
|
||||
let bytes_left = FILE_SIZE
|
||||
let decodedLength = 0
|
||||
let chunkNumber = 0
|
||||
|
||||
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(filebuf.file.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - filebuf.file.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
let [cy, cx] = con.getyx()
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
let chunkNumber = 0
|
||||
|
||||
function bytesToSec(i) {
|
||||
// Approximate: use first chunk's ratio
|
||||
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
||||
}
|
||||
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
function printPlayBar() {
|
||||
if (interactive) {
|
||||
let currently = decodedLength
|
||||
let total = FILE_SIZE
|
||||
|
||||
let currentlySec = bytesToSec(currently)
|
||||
let totalSec = bytesToSec(total)
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
||||
audio.setPcmQueueCapacityIndex(0, 2)
|
||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
audio.play(0)
|
||||
|
||||
if (interactive) {
|
||||
gui.audioInit({
|
||||
title: `${filebuf.file.name} TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`,
|
||||
tag: "TAD"
|
||||
})
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
let errorlevel = 0
|
||||
|
||||
try {
|
||||
while (bytes_left > 0 && !stopPlay) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) { // Backspace key
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
const sampleCount = filebuf.readShort()
|
||||
const maxIndex = filebuf.readByte()
|
||||
const payloadSize = filebuf.readInt()
|
||||
|
||||
printPlayBar()
|
||||
|
||||
// Read TAD chunk header (standalone TAD format)
|
||||
// Format: [sample_count][max_index][payload_size][payload]
|
||||
let sampleCount = filebuf.readShort()
|
||||
let maxIndex = filebuf.readByte()
|
||||
let payloadSize = filebuf.readInt()
|
||||
|
||||
// Validate every chunk (not just first one)
|
||||
if (sampleCount < 0 || sampleCount > 65536) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}. File may be corrupted.`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
if (maxIndex < 0 || maxIndex > 255) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}. File may be corrupted.`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
if (payloadSize < 1 || payloadSize > 65536) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}. File may be corrupted.`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
if (payloadSize + 7 > bytes_left) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size ${payloadSize + 7} exceeds remaining file size ${bytes_left}`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size exceeds remaining file size.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
|
||||
if (dumpCoeffs && chunkNumber < 3) {
|
||||
@@ -282,80 +156,59 @@ try {
|
||||
serial.println(` Sample Count: ${sampleCount}`)
|
||||
serial.println(` Max Index: ${maxIndex}`)
|
||||
serial.println(` Payload Size: ${payloadSize} bytes`)
|
||||
serial.println(` Bytes remaining in file: ${bytes_left}`)
|
||||
}
|
||||
|
||||
// Rewind 7 bytes to re-read the header along with payload
|
||||
// This allows reading the complete chunk (header + payload) in one call
|
||||
// Read entire chunk (header + payload) into TAD input buffer.
|
||||
filebuf.unread(7)
|
||||
filebuf.readBytes(7 + payloadSize, TAD_INPUT_ADDR)
|
||||
|
||||
// Read entire chunk (header + payload) to TAD input buffer
|
||||
// This matches TAV's approach for packet 0x24
|
||||
let totalChunkSize = 7 + payloadSize
|
||||
filebuf.readBytes(totalChunkSize, TAD_INPUT_ADDR)
|
||||
|
||||
if (dumpCoeffs && chunkNumber < 3) {
|
||||
// Dump first 32 bytes of compressed payload (skip 7-byte header)
|
||||
serial.print(` Compressed data (first 32 bytes): `)
|
||||
for (let i = 0; i < Math.min(32, payloadSize); i++) {
|
||||
let b = sys.peek(TAD_INPUT_ADDR + 7 + i)
|
||||
serial.print(`${(b & 0xFF).toString(16).padStart(2, '0')} `)
|
||||
}
|
||||
serial.println('')
|
||||
}
|
||||
|
||||
// Decode TAD chunk
|
||||
audio.tadDecode()
|
||||
|
||||
if (dumpCoeffs && chunkNumber < 3) {
|
||||
// After decoding, the decoded PCMu8 samples are in tadDecodedBin
|
||||
serial.println(` Decoded ${sampleCount} samples`)
|
||||
|
||||
// Dump first 16 decoded samples (PCMu8 stereo interleaved)
|
||||
serial.print(` Decoded (first 16 L samples): `)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
serial.print(`${sys.peek(TAD_DECODED_ADDR + i * 2) & 0xFF} `)
|
||||
}
|
||||
serial.println('')
|
||||
serial.print(` Decoded (first 16 R samples): `)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
serial.print(`${sys.peek(TAD_DECODED_ADDR + i * 2 + 1) & 0xFF} `)
|
||||
}
|
||||
serial.println('')
|
||||
serial.println('')
|
||||
}
|
||||
|
||||
// Upload decoded audio to queue
|
||||
audio.tadUploadDecoded(0, sampleCount)
|
||||
// After upload tadDecodedBin still holds the chunk until the next
|
||||
// tadDecode call, so it's safe to keep slicing samples out of it
|
||||
// during the playback wait below.
|
||||
|
||||
if (!dumpCoeffs) {
|
||||
// Sleep for the duration of the audio chunk to pace playback
|
||||
// This prevents uploading everything at once
|
||||
sys.sleep(bufRealTimeLen)
|
||||
// TAD chunks are typically 1 s long, so feeding the visualiser
|
||||
// once would freeze it for ~1 s. Walk the chunk in 2048-sample
|
||||
// slices (~64 ms each at 32 kHz) so the wavescope and XY-scope
|
||||
// stay in step with what the audio engine is actually playing.
|
||||
const chunkMs = Math.floor((sampleCount / SAMPLE_RATE) * 1000)
|
||||
const TAD_VIS_SLICE = 2048
|
||||
if (interactive) {
|
||||
gui.audioSetProgress(decodedLength / FILE_SIZE,
|
||||
bytesToSec(decodedLength), bytesToSec(FILE_SIZE))
|
||||
let sliceOff = 0
|
||||
while (sliceOff < sampleCount && !stopPlay) {
|
||||
if (gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
const sliceN = Math.min(TAD_VIS_SLICE, sampleCount - sliceOff)
|
||||
// tadDecodedBin is negative-addressed: sample i sits at
|
||||
// TAD_DECODED_ADDR - i*2. audioFeedPcm flips the read
|
||||
// direction for negative ptrs internally.
|
||||
gui.audioFeedPcm(TAD_DECODED_ADDR - sliceOff * 2, sliceN)
|
||||
gui.audioRender()
|
||||
sys.sleep(Math.floor((sliceN / SAMPLE_RATE) * 1000))
|
||||
sliceOff += sliceN
|
||||
}
|
||||
} else {
|
||||
sys.sleep(chunkMs)
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk size = header (7 bytes) + payload
|
||||
let chunkSize = 7 + payloadSize
|
||||
bytes_left -= chunkSize
|
||||
const chunkSize = 7 + payloadSize
|
||||
bytes_left -= chunkSize
|
||||
decodedLength += chunkSize
|
||||
chunkNumber++
|
||||
|
||||
// Limit coefficient dump to first 3 chunks
|
||||
if (dumpCoeffs && chunkNumber >= 3) {
|
||||
serial.println(`... (remaining chunks omitted)`)
|
||||
// Keep playing but don't dump more
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
if (interactive) {
|
||||
con.move(cy + 3, 1)
|
||||
con.curs_set(1)
|
||||
}
|
||||
} finally {
|
||||
if (interactive) gui.audioClose()
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
|
||||
1054
assets/disk0/tvdos/bin/playtaud.js
Normal file
1054
assets/disk0/tvdos/bin/playtaud.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1746,7 +1746,9 @@ try {
|
||||
tadInitialised = true
|
||||
}
|
||||
|
||||
seqread.readBytes(payloadLen, SND_MEM_ADDR - 262144)
|
||||
// tadInputBin lives at audio-local offset 917504 (post-bef85f6 memory map);
|
||||
// the previous 262144 offset now points into the enlarged sampleBin.
|
||||
seqread.readBytes(payloadLen, SND_MEM_ADDR - 917504)
|
||||
audio.tadDecode()
|
||||
audio.tadUploadDecoded(AUDIO_DEVICE, sampleLen)
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ for (let i = 0; i < cueElements.length; i++) {
|
||||
// Execute the player with modified environment
|
||||
exec_args[1] = targetPath
|
||||
if (playerFile) {
|
||||
let playerPath = `A:\\tvdos\\bin\\${playerFile}.js`
|
||||
let playerPath = `A:${_TVDOS.variables.DOSDIR}/bin/${playerFile}.js`
|
||||
if (files.open(playerPath).exists) {
|
||||
eval(files.readText(playerPath))
|
||||
} else {
|
||||
@@ -334,7 +334,7 @@ for (let i = 0; i < cueElements.length; i++) {
|
||||
}
|
||||
|
||||
// Execute the appropriate player
|
||||
let playerPath = `A:\\tvdos\\bin\\${playerFile}.js`
|
||||
let playerPath = `A:${_TVDOS.variables.DOSDIR}/bin/${playerFile}.js`
|
||||
if (!files.open(playerPath).exists) {
|
||||
serial.println(`Warning: Player script not found: ${playerPath}`)
|
||||
continue
|
||||
|
||||
@@ -1,329 +1,189 @@
|
||||
// usage: playwav audiofile.wav [/i]
|
||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
let filename = fileeeee.fullPath
|
||||
// playwav — WAV (LPCM/ADPCM) player with the shared playgui visualiser.
|
||||
// Usage: playwav <file.wav> [-i]
|
||||
|
||||
const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const filePath = fileHandle.fullPath
|
||||
|
||||
const WAV_FORMATS = ["LPCM", "ADPCM"]
|
||||
const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"]
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
|
||||
const seqread = require("seqread")
|
||||
const pcm = require("pcm")
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
|
||||
const WAV_FORMATS = ["LPCM", "ADPCM"]
|
||||
const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"]
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const seqread = require("seqread")
|
||||
const pcm = require("pcm")
|
||||
|
||||
|
||||
|
||||
function printComments() {
|
||||
for (const [key, value] of Object.entries(comments)) {
|
||||
printdbg(`Wave Comment ${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function GCD(a, b) {
|
||||
a = Math.abs(a)
|
||||
b = Math.abs(b)
|
||||
if (b > a) {var temp = a; a = b; b = temp}
|
||||
a = Math.abs(a); b = Math.abs(b)
|
||||
if (b > a) { const t = a; a = b; b = t }
|
||||
while (true) {
|
||||
if (b == 0) return a
|
||||
if (b === 0) return a
|
||||
a %= b
|
||||
if (a == 0) return b
|
||||
if (a === 0) return b
|
||||
b %= a
|
||||
}
|
||||
}
|
||||
function LCM(a, b) { return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b)) }
|
||||
|
||||
function LCM(a, b) {
|
||||
return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b))
|
||||
}
|
||||
|
||||
|
||||
|
||||
//println("Reading...")
|
||||
//serial.println("!!! READING")
|
||||
|
||||
seqread.prepare(filename)
|
||||
|
||||
|
||||
|
||||
|
||||
// decode header
|
||||
if (seqread.readFourCC() != "RIFF") {
|
||||
throw Error("File not RIFF")
|
||||
}
|
||||
|
||||
const FILE_SIZE = seqread.readInt() // size from "WAVEfmt"
|
||||
|
||||
if (seqread.readFourCC() != "WAVE") {
|
||||
throw Error("File is RIFF but not WAVE")
|
||||
}
|
||||
seqread.prepare(filePath)
|
||||
if (seqread.readFourCC() !== "RIFF") throw Error("File not RIFF")
|
||||
const FILE_SIZE = seqread.readInt()
|
||||
if (seqread.readFourCC() !== "WAVE") throw Error("File is RIFF but not WAVE")
|
||||
|
||||
let BLOCK_SIZE = 0
|
||||
let INFILE_BLOCK_SIZE = 0
|
||||
const QUEUE_MAX = 8 // according to the spec
|
||||
const QUEUE_MAX = 8
|
||||
|
||||
let pcmType;
|
||||
let nChannels;
|
||||
let samplingRate;
|
||||
let blockSize;
|
||||
let bitsPerSample;
|
||||
let byterate;
|
||||
let comments = {};
|
||||
let adpcmSamplesPerBlock;
|
||||
let readPtr = undefined
|
||||
let decodePtr = undefined
|
||||
let pcmType, nChannels, samplingRate, blockSize, bitsPerSample, byterate
|
||||
let adpcmSamplesPerBlock
|
||||
let readPtr, decodePtr
|
||||
const comments = {}
|
||||
|
||||
function bytesToSec(i) {
|
||||
if (adpcmSamplesPerBlock) {
|
||||
let newByteRate = samplingRate
|
||||
let generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
||||
return generatedSamples / newByteRate
|
||||
}
|
||||
else {
|
||||
return i / byterate
|
||||
const generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
||||
return generatedSamples / samplingRate
|
||||
}
|
||||
return i / byterate
|
||||
}
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
function checkIfPlayable() {
|
||||
if (pcmType != 1 && pcmType != 2) return `PCM Type not LPCM/ADPCM (${pcmType})`
|
||||
if (pcmType !== 1 && pcmType !== 2) return `PCM Type not LPCM/ADPCM (${pcmType})`
|
||||
if (nChannels < 1 || nChannels > 2) return `Audio not mono/stereo but instead has ${nChannels} channels`
|
||||
if (pcmType != 1 && samplingRate != pcm.HW_SAMPLING_RATE) return `Format is ADPCM but sampling rate is not ${pcm.HW_SAMPLING_RATE}: ${samplingRate}`
|
||||
if (pcmType !== 1 && samplingRate !== pcm.HW_SAMPLING_RATE)
|
||||
return `Format is ADPCM but sampling rate is not ${pcm.HW_SAMPLING_RATE}: ${samplingRate}`
|
||||
return "playable!"
|
||||
}
|
||||
// @return decoded sample length (not count!)
|
||||
|
||||
function decodeInfilePcm(inPtr, outPtr, inputLen) {
|
||||
// LPCM
|
||||
if (1 == pcmType)
|
||||
if (pcmType === 1)
|
||||
return pcm.decodeLPCM(inPtr, outPtr, inputLen, { nChannels, bitsPerSample, samplingRate, blockSize })
|
||||
else if (2 == pcmType)
|
||||
if (pcmType === 2)
|
||||
return pcm.decodeMS_ADPCM(inPtr, outPtr, inputLen, { nChannels })
|
||||
else
|
||||
throw Error(`PCM Type not LPCM or ADPCM (${pcmType})`)
|
||||
throw Error(`PCM Type not LPCM or ADPCM (${pcmType})`)
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
|
||||
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
function printPlayerShell() {
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(fileeeee.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - fileeeee.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `WAV ${WAV_FORMATS[pcmType-1]} ${WAV_CHANNELS[nChannels-1]} ${byterate*0.008*(pcmType == 2 ? 2 : 1)}kbps`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
}
|
||||
let [cy, cx] = con.getyx(); cy++
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
function printPlayBar(startOffset) {
|
||||
if (interactive) {
|
||||
let currently = seqread.getReadCount() - startOffset
|
||||
let total = FILE_SIZE - startOffset - 8
|
||||
|
||||
let currentlySec = Math.round(bytesToSec(currently))
|
||||
let totalSec = Math.round(bytesToSec(total))
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
}
|
||||
let errorlevel = 0
|
||||
// read chunks loop
|
||||
try {
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
||||
let chunkName = seqread.readFourCC()
|
||||
let chunkSize = seqread.readInt()
|
||||
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
||||
|
||||
// here be lotsa if-else
|
||||
if ("fmt " == chunkName) {
|
||||
pcmType = seqread.readShort()
|
||||
nChannels = seqread.readShort()
|
||||
samplingRate = seqread.readInt()
|
||||
byterate = seqread.readInt()
|
||||
blockSize = seqread.readShort()
|
||||
bitsPerSample = seqread.readShort()
|
||||
if (pcmType != 2) {
|
||||
seqread.skip(chunkSize - 16)
|
||||
try {
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
||||
const chunkName = seqread.readFourCC()
|
||||
const chunkSize = seqread.readInt()
|
||||
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
||||
|
||||
if (chunkName === "fmt ") {
|
||||
pcmType = seqread.readShort()
|
||||
nChannels = seqread.readShort()
|
||||
samplingRate = seqread.readInt()
|
||||
byterate = seqread.readInt()
|
||||
blockSize = seqread.readShort()
|
||||
bitsPerSample = seqread.readShort()
|
||||
if (pcmType !== 2) {
|
||||
seqread.skip(chunkSize - 16)
|
||||
} else {
|
||||
seqread.skip(2)
|
||||
adpcmSamplesPerBlock = seqread.readShort()
|
||||
seqread.skip(chunkSize - (16 + 4))
|
||||
}
|
||||
|
||||
if (pcmType === 1) {
|
||||
const incr = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE))
|
||||
while (BLOCK_SIZE < 4096) BLOCK_SIZE += incr
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8
|
||||
} else if (pcmType === 2) {
|
||||
BLOCK_SIZE = blockSize
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
const tag = "WAV"
|
||||
const title = fileHandle.name +
|
||||
` ${WAV_FORMATS[pcmType-1]} ${WAV_CHANNELS[nChannels-1]} ${byterate*0.008*(pcmType === 2 ? 2 : 1)}kbps`
|
||||
gui.audioInit({ title, tag })
|
||||
}
|
||||
}
|
||||
else if (chunkName === "LIST") {
|
||||
const startOffset = seqread.getReadCount()
|
||||
const subChunkName = seqread.readFourCC()
|
||||
while (seqread.getReadCount() < startOffset + chunkSize) {
|
||||
if (subChunkName === "INFO") {
|
||||
let key = seqread.readFourCC()
|
||||
let valueLen = seqread.readInt()
|
||||
while (key.charCodeAt(0) === 0) {
|
||||
const kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255]
|
||||
const klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()]
|
||||
key = String.fromCharCode.apply(null, kbytes)
|
||||
valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24)
|
||||
}
|
||||
comments[key] = seqread.readString(valueLen)
|
||||
} else {
|
||||
seqread.skip(startOffset + chunkSize - seqread.getReadCount())
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (chunkName === "data") {
|
||||
const startOffset = seqread.getReadCount()
|
||||
const reason = checkIfPlayable()
|
||||
if (reason !== "playable!") throw Error("WAVE not playable: " + reason)
|
||||
|
||||
readPtr = sys.malloc(pcmType === 2 ? BLOCK_SIZE : BLOCK_SIZE * bitsPerSample / 8)
|
||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
|
||||
let readLength = 1
|
||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
if (audio.getPosition(0) <= 1) {
|
||||
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||
const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) break
|
||||
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
const decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength)
|
||||
|
||||
// Hand the decoded PCMu8 stereo block to the visualiser
|
||||
// before queueing — the buffer is reused next iteration.
|
||||
if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1)
|
||||
|
||||
audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0)
|
||||
audio.setSampleUploadLength(0, decodedSampleLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
sys.spin()
|
||||
}
|
||||
audio.play(0)
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
const cur = seqread.getReadCount() - startOffset
|
||||
const tot = FILE_SIZE - startOffset - 8
|
||||
gui.audioSetProgress(cur / tot, bytesToSec(cur), bytesToSec(tot))
|
||||
gui.audioRender()
|
||||
}
|
||||
sys.sleep(10)
|
||||
}
|
||||
}
|
||||
else {
|
||||
seqread.skip(2)
|
||||
adpcmSamplesPerBlock = seqread.readShort()
|
||||
seqread.skip(chunkSize - (16 + 4))
|
||||
seqread.skip(chunkSize)
|
||||
}
|
||||
|
||||
// define BLOCK_SIZE as integer multiple of blockSize, for LPCM
|
||||
// ADPCM will be decoded per-block basis
|
||||
if (1 == pcmType) {
|
||||
// get GCD of given values; this wll make resampling headache-free
|
||||
let blockSizeIncrement = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE))
|
||||
|
||||
while (BLOCK_SIZE < 4096) {
|
||||
BLOCK_SIZE += blockSizeIncrement // for rate 44100, BLOCK_SIZE will be 4116
|
||||
}
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8 // for rate 44100, INFILE_BLOCK_SIZE will be 8232
|
||||
}
|
||||
else if (2 == pcmType) {
|
||||
BLOCK_SIZE = blockSize
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
}
|
||||
|
||||
printdbg(`Format: ${pcmType}, Channels: ${nChannels}, Rate: ${samplingRate}, BitDepth: ${bitsPerSample}`)
|
||||
printdbg(`BLOCK_SIZE=${BLOCK_SIZE}, INFILE_BLOCK_SIZE=${INFILE_BLOCK_SIZE}`)
|
||||
printPlayerShell()
|
||||
sys.spin()
|
||||
}
|
||||
else if ("LIST" == chunkName) {
|
||||
let startOffset = seqread.getReadCount()
|
||||
let subChunkName = seqread.readFourCC()
|
||||
while (seqread.getReadCount() < startOffset + chunkSize) {
|
||||
if ("INFO" == subChunkName) {
|
||||
let key = seqread.readFourCC()
|
||||
let valueLen = seqread.readInt()
|
||||
|
||||
// f-you WAVE encoders with nonstandard behaviours
|
||||
// related: https://stackoverflow.com/questions/49537639/riff-icmt-tag-size-doesnt-seem-to-match-data
|
||||
while (0 == key.charCodeAt(0)) {
|
||||
printdbg(`Previous key had more zero bytes padded than its marked length, skipping one byte...`)
|
||||
|
||||
let kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255]
|
||||
let klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()]
|
||||
|
||||
key = String.fromCharCode.apply(null, kbytes)
|
||||
valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24)
|
||||
}
|
||||
|
||||
printdbg(`Reading LIST INFO ${key}[${[0,1,2,3].map((i)=>"0x"+key.charCodeAt(i).toString(16).padStart(2,'0'))}] (${valueLen} bytes): `)
|
||||
|
||||
|
||||
let value = seqread.readString(valueLen)
|
||||
printdbg(" |"+value)
|
||||
comments[key] = value
|
||||
}
|
||||
else {
|
||||
printdbg(`LIST skip subchunk ${subChunkName} (${startOffset + chunkSize - seqread.getReadCount()} bytes)`)
|
||||
seqread.skip(startOffset + chunkSize - seqread.getReadCount())
|
||||
}
|
||||
}
|
||||
printComments()
|
||||
}
|
||||
else if ("data" == chunkName) {
|
||||
let startOffset = seqread.getReadCount()
|
||||
|
||||
printdbg(`WAVE size: ${chunkSize}, startOffset=${startOffset}`)
|
||||
// check if the format is actually playable
|
||||
let unplayableReason = checkIfPlayable()
|
||||
if (unplayableReason != "playable!") throw Error("WAVE not playable: "+unplayableReason)
|
||||
|
||||
if (pcmType == 2)
|
||||
readPtr = sys.malloc(BLOCK_SIZE)
|
||||
else
|
||||
readPtr = sys.malloc(BLOCK_SIZE * bitsPerSample / 8)
|
||||
|
||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
|
||||
let readLength = 1
|
||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) {
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
|
||||
printPlayBar(startOffset)
|
||||
|
||||
let queueSize = audio.getPosition(0)
|
||||
if (queueSize <= 1) {
|
||||
|
||||
|
||||
// upload four samples for lag-safely
|
||||
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) {
|
||||
printdbg(`readLength = ${readLength}`)
|
||||
break
|
||||
}
|
||||
|
||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE + 8}; readLength: ${readLength}`)
|
||||
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
|
||||
let decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength)
|
||||
printdbg(` decodedSampleLength: ${decodedSampleLength}`)
|
||||
|
||||
audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0)
|
||||
audio.setSampleUploadLength(0, decodedSampleLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
sys.spin()
|
||||
}
|
||||
|
||||
audio.play(0)
|
||||
}
|
||||
|
||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()}; startOffset + chunkSize = ${startOffset + chunkSize}`)
|
||||
|
||||
|
||||
sys.sleep(10)
|
||||
}
|
||||
}
|
||||
else {
|
||||
seqread.skip(chunkSize)
|
||||
}
|
||||
|
||||
|
||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
printdbg(`remainingBytes2 = ${remainingBytes}`)
|
||||
sys.spin()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
//audio.stop(0)
|
||||
if (readPtr !== undefined) sys.free(readPtr)
|
||||
} finally {
|
||||
if (readPtr !== undefined) sys.free(readPtr)
|
||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
||||
if (interactive) gui.audioClose()
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
if (!_G.TAUT) _G.TAUT = {};
|
||||
let help = {}
|
||||
|
||||
let ts = require("typesetter")
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/*
|
||||
@@ -11,331 +13,159 @@ Tags:
|
||||
<l> - align left
|
||||
<o> - create virtual typesetting box. Left anchor: where the text cursor is. Right anchor: end of the line
|
||||
µtone; - replace with the brand string (<col 211>Micro</col><col 239>tone</col>)
|
||||
|
||||
&bul; - replace with bullet (\u00F9)
|
||||
&ddot; - replace with double-dot (\u008419u)
|
||||
&mdot; - replace with BIGDOT (\u00FA)
|
||||
&updn; - up-down arrow (\u008418u)
|
||||
&udlr; - four direction arrow (\u008428u\u008429u)
|
||||
&keyoffsym; - pattern view key-off symbol (\u00A0\u00CD\u00CD\u00A1)
|
||||
|
||||
&keyoffsym; - pattern view key-off symbol (\u00A0\u00B1\u00B1\u00A1)
|
||||
¬ecutsym; - pattern view note-cut symbol (\u00A4\u00A4\u00A4\u00A4)
|
||||
|
||||
&demisharp;
|
||||
♯
|
||||
&sesquisharp;
|
||||
&doublesharp;
|
||||
&triplesharp;
|
||||
&quadsharp;
|
||||
&demiflat;
|
||||
♭
|
||||
&sesquiflat;
|
||||
&doubleflat;
|
||||
&tripleflat;
|
||||
&quadflat;
|
||||
&accuptick;
|
||||
&accdntick;
|
||||
&accupup;
|
||||
&accdndn;
|
||||
|
||||
- nonbreakable space (only meaningful for typesetters)
|
||||
­ - soft hyphen (only meaningful for typesetters)
|
||||
|
||||
default alignment: fully justified
|
||||
*/
|
||||
|
||||
let helpNotation = `<c>CONTROL NOTATON</c>
|
||||
|
||||
let helpNotation = `<c>CONTROL NOTATION</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(16)}\u00B9</c>
|
||||
µtone; <O>shortcuts differentiate normal and shifted shortcuts.</O>
|
||||
&bul;<b>a</b>&ddot;<b>z</b> : <O>alphabet without shift-in</O>
|
||||
&bul;<b>A</b>&ddot;<b>Z</b> : <O>alphabet with shift-in</O>
|
||||
&bul;<b>^q</b> : <O>hit 'q' with control key</O>
|
||||
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>`
|
||||
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let helpJam = `<c>NOTE JAMMING</c>
|
||||
|
||||
<c>\u00B7${'\u00B8'.repeat(12)}\u00B9</c>
|
||||
Push keys to play or insert notes.
|
||||
w e t y u
|
||||
a s d f g h j k`
|
||||
a s d f g h j k
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let helpCommon = `<c>COMMON CONTROLS</c>
|
||||
|
||||
<c>\u00B7${'\u00B8'.repeat(15)}\u00B9</c>
|
||||
&bul;<b>!</b> : <O>show this help message</O>
|
||||
&bul;<b>Y</b> : <O>play the entire song from the current cue</O>
|
||||
&bul;<b>U</b> : <O>play the current cue then stop</O>
|
||||
&bul;<b>I</b> : <O>play the current row</O>
|
||||
&bul;<b>O</b> : <O>stop the playback</O>
|
||||
&bul;<b>tab</b> : <O>switch forward a tab</O>
|
||||
&bul;<b>TAB</b> : <O>switch backward a tab</O>
|
||||
&bul;<b>q</b> : <O>close µtone;</O>`
|
||||
&bul;<b>Y</b> : <O>plays the entire song from the current cue</O>
|
||||
&bul;<b>U</b> : <O>plays the current cue then stop</O>
|
||||
&bul;<b>I</b> : <O>plays the current row</O>
|
||||
&bul;<b>O</b> : <O>stops the playback</O>
|
||||
&bul;<b>tab</b> : <O>switchs forward a tab</O>
|
||||
&bul;<b>TAB</b> : <O>switchs backward a tab</O>
|
||||
&bul;<b>q</b> : <O>closes µtone;</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let helpTimeline = `<c>TIMELINE VIEW</c>
|
||||
|
||||
<c>\u00B7${'\u00B8'.repeat(13)}\u00B9</c>
|
||||
Timeline has two distinct modes: view and edit mode. Two modes are toggled using the space bar.
|
||||
|
||||
<b>VIEW MODE</b>
|
||||
<b> VIEW MODE</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||
&bul;Note jamming : <O>plays the note</O>
|
||||
&bul;<b>&udlr;</b> : <O>move the viewing cursor by voices and rows</O>
|
||||
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</O>
|
||||
&bul;<b>W</b>&mdot;<b>E</b>&mdot;<b>R</b> : <O>toggle timeline view mode. W-most detailed, R-most abridged</O>
|
||||
&bul;<b>n</b> : <O>toggle soloing of the selected voice</O>
|
||||
&bul;<b>m</b> : <O>toggle muting of the selected voice</O>
|
||||
&bul;<b>&udlr;</b> : <O>moves the viewing cursor by voices and rows</O>
|
||||
&bul;<b>pg&updn;</b> : <O>goes to previous/next cue</O>
|
||||
&bul;<b>W</b>&mdot;<b>E</b>&mdot;<b>R</b> : <O>toggles timeline view mode. W-most detailed, R-most abridged</O>
|
||||
&bul;<b>n</b> : <O>toggles soloing of the selected voice</O>
|
||||
&bul;<b>m</b> : <O>toggles muting of the selected voice</O>
|
||||
&bul;<b>[</b>&mdot;<b>]</b> : <O>changes tick rate of playhead</O>
|
||||
|
||||
<b>EDIT MODE</b>
|
||||
<b> EDIT MODE</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||
&bul;Note jamming : <O>(note column) inserts the note</O>
|
||||
&bul;<b>{</b>&mdot;<b>}</b> : <O>(note column) lower/raise a note by one octave (or period)</O>
|
||||
&bul;<b>[</b>&mdot;<b>]</b> : <O>(note column) lower/raise a note by one unit</O>
|
||||
&bul;<b>=</b> : <O>(note column) insert a key-off &keyoffsym;</O>
|
||||
&bul;<b>^</b> : <O>(note column) insert a note-cut ¬ecutsym;</O>
|
||||
&bul;<b>.</b> : <O>remove a symbol on the selected column</O>
|
||||
&bul;<b>bksp</b> : <O>delete one character on the selected column</O>
|
||||
&bul;<b>{</b>&mdot;<b>}</b> : <O>(note column) lowers/raises a note by one octave (or period)</O>
|
||||
&bul;<b>[</b>&mdot;<b>]</b> : <O>(note column) lowers/raises a note by one unit</O>
|
||||
&bul;<b>z</b> : <O>(note column) inserts a key-off &keyoffsym;</O>
|
||||
&bul;<b>x</b> : <O>(note column) inserts a note-cut ¬ecutsym;</O>
|
||||
&bul;<b>.</b> : <O>clears fields</O>
|
||||
&bul;<b>bksp</b> : <O>deletes one character on the selected column</O>
|
||||
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>f</b> : <O>inserts a (hexa)decimal number</O>
|
||||
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>z</b> : <O>(fx column) inserts an effect</O>
|
||||
&bul;<b>^</b>&mdot;<b>v</b> : <O>(volume column) slide up/down</O>
|
||||
&bul;<b><</b>&mdot;<b>></b>: <O>(panning column) slide left/right</O>
|
||||
&bul;<b>-</b>&mdot;<b>=</b> : <O>(vol/pan col) fine slide down/up</O>
|
||||
&bul;<b>&udlr;</b> : <O>move the viewing cursor by columns and rows</O>
|
||||
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</O>`
|
||||
&bul;<b>&udlr;</b> : <O>moves the viewing cursor by columns and rows</O>
|
||||
&bul;<b>pg&updn;</b> : <O>goes to previous/next cue</O>
|
||||
|
||||
<b> ACCIDENTALS</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(11)}\u00B9</b>
|
||||
&demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat; &accuptick; &accupup; &accdntick; &accdndn;
|
||||
<b>C c cx x xx B b bb bbb ^ ^^ v vv</b>
|
||||
|
||||
<b> GLOBAL EDIT</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(11)}\u00B9</b>
|
||||
&bul;<b>Q</b> : <O>retunes current song into different tuning and strategy. In general, nearest-note works best for macrotonals, nearest-harmonic and nearest-delta works best for highly microtonals (31+); 17- and 19-TET takes nearest-harmonic pretty well, while 22-TET seem to only benefit from the nearest-note</O>
|
||||
`
|
||||
|
||||
let helpProjectFlags = `<c>MIXER FLAGS</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(11)}\u00B9</c>
|
||||
Mixer flags define how should the mixer behave.
|
||||
|
||||
<b> TONE MODE</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||
&bul;Linear pitch : <O>pitch shift effects operate on linear pitch scale. The default and recommended setting for a new project</O>
|
||||
&bul;Amiga pitch : <O>pitch shift effects operate on Amiga period scale. Backwards compatible setting for MOD/S3M/XM/IT formats</O>
|
||||
&bul;Linear freq : <O>pitch shift effects operate on linear frequency scale. Backwards compatible setting for MONOTONE format</O>
|
||||
|
||||
<b> INTERPOLATION</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(13)}\u00B9</b>
|
||||
&bul;Default : <O>three-tap fast sinc interpolation. The default and recommended setting for a new project</O>
|
||||
&bul;None : <O>zeroth-order hold</O>
|
||||
&bul;A500 : <O>emulates what Paula chip of Amiga 500 does. <b>S 0x00</b> effects only work with this and Amiga 1200 mode</O>
|
||||
&bul;A1200 : <O>emulates what Paula chip of Amiga 1200 does</O>
|
||||
&bul;SNES : <O>four-tap gaussian interpolation used by SNES</O>
|
||||
&bul;DPCM : <O>simulates Differential Pulse Code Modulation used by NES</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// assemble help text pieces to complete help message
|
||||
|
||||
const SCRW = con.getmaxyx()[1]
|
||||
const HRULE = '\u00B4\u00B5'.repeat((_G.TAUT.HELPMSG_WIDTH) >>> 1) + '\n'
|
||||
|
||||
// Display-command palette. taut.js's popup uses (HELP_COL_TEXT on background) as the
|
||||
// default colour pair, so embedded `\x1B[38;5;Nm` codes switch foreground only.
|
||||
const HELP_COL_TEXT = 239 // popup body default (== colWHITE)
|
||||
const HELP_COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
||||
const HELP_COL_BRAND = 211 // first half of "Microtone"
|
||||
const HELP_COL_BRAND_DIM = 239 // second half of "Microtone"
|
||||
|
||||
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
||||
const ESC_DEFAULT = fgEsc(HELP_COL_TEXT)
|
||||
const ESC_EMPH = fgEsc(HELP_COL_EMPH)
|
||||
const MICROTONE = `${fgEsc(HELP_COL_BRAND)}Micro${fgEsc(HELP_COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
||||
|
||||
// Replace &xxx; entities with their final printable representations.
|
||||
function expandEntities(s) {
|
||||
return s
|
||||
.replaceAll('µtone;', MICROTONE)
|
||||
.replaceAll('&bul;', '\u00F9')
|
||||
.replaceAll('&ddot;', '\u008419u')
|
||||
.replaceAll('&mdot;', '\u00FA')
|
||||
.replaceAll('&updn;', '\u008418u')
|
||||
.replaceAll('&udlr;', '\u008428u\u008429u')
|
||||
.replaceAll('&keyoffsym;', '\u00A0\u00CD\u00CD\u00A1')
|
||||
.replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
|
||||
.replaceAll(' ', '\u007F')
|
||||
.replaceAll('­', '')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
}
|
||||
|
||||
// Tokenise a (post-entity-expansion) line. Returns an array of:
|
||||
// {type:'word', text:String, w:int} - non-breakable run of visible chars (may carry ANSI escapes)
|
||||
// {type:'sp'} - a single soft space (eligible for break/expansion)
|
||||
// {type:'anchor', open:Boolean} - <o>/</o> markers (zero width)
|
||||
//
|
||||
// Width accounting:
|
||||
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
|
||||
// - TSVM unicode escapes (`..u`) : 1 visible char
|
||||
// - non-breaking space ( ) : 1 visible char (consumed as part of a word)
|
||||
// - soft hyphen () : dropped (not implemented as a break point)
|
||||
// - everything else : 1 visible char
|
||||
function tokenise(line) {
|
||||
const tokens = []
|
||||
let buf = ''
|
||||
let bufW = 0
|
||||
let i = 0
|
||||
|
||||
const flushWord = () => {
|
||||
if (buf.length > 0) {
|
||||
tokens.push({type: 'word', text: buf, w: bufW})
|
||||
buf = ''
|
||||
bufW = 0
|
||||
}
|
||||
}
|
||||
|
||||
while (i < line.length) {
|
||||
// inline tags (case-sensitive for <b>, case-insensitive for <o>)
|
||||
if (line.slice(i, i + 3) === '<b>') { buf += ESC_EMPH; i += 3; continue }
|
||||
if (line.slice(i, i + 4) === '</b>') { buf += ESC_DEFAULT; i += 4; continue }
|
||||
const head3 = line.slice(i, i + 3).toLowerCase()
|
||||
const head4 = line.slice(i, i + 4).toLowerCase()
|
||||
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
||||
if (head4 === '</o>') { flushWord(); tokens.push({type: 'anchor', open: false}); i += 4; continue }
|
||||
|
||||
const c = line[i]
|
||||
const cc = line.charCodeAt(i)
|
||||
|
||||
if (cc === 0x1B) {
|
||||
// pre-existing ANSI escape - copy verbatim, zero visible width
|
||||
const m = line.indexOf('m', i)
|
||||
const end = (m < 0) ? line.length : m + 1
|
||||
buf += line.slice(i, end)
|
||||
i = end
|
||||
}
|
||||
else if (cc === 0x84) {
|
||||
// TSVM <digits>u escape - copy verbatim, one visible char
|
||||
const u = line.indexOf('u', i)
|
||||
const end = (u < 0) ? line.length : u + 1
|
||||
buf += line.slice(i, end)
|
||||
bufW += 1
|
||||
i = end
|
||||
}
|
||||
else if (c === ' ') {
|
||||
flushWord()
|
||||
tokens.push({type: 'sp'})
|
||||
i += 1
|
||||
}
|
||||
else if (cc === 0x00AD) {
|
||||
// soft hyphen: drop (no break-point handling for now)
|
||||
i += 1
|
||||
}
|
||||
else {
|
||||
buf += c
|
||||
bufW += 1
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
flushWord()
|
||||
return tokens
|
||||
}
|
||||
|
||||
// Build wrapped lines from a token stream then format each one according to alignment.
|
||||
// Returns an array of strings, each exactly `width` visible chars wide (padded with
|
||||
// trailing spaces) so the caller can blit them without further math.
|
||||
function wrapAndAlign(tokens, width, alignment) {
|
||||
const lines = [] // each: {tokens, indent, contentW}
|
||||
let curTokens = []
|
||||
let curW = 0
|
||||
let curIndent = 0
|
||||
let nextIndent = 0 // indent the *next* flushed line should use
|
||||
|
||||
const flushLine = () => {
|
||||
// strip trailing soft spaces
|
||||
while (curTokens.length > 0 && curTokens[curTokens.length - 1].type === 'sp') {
|
||||
curTokens.pop()
|
||||
curW -= 1
|
||||
}
|
||||
lines.push({tokens: curTokens, indent: curIndent, contentW: curW})
|
||||
curTokens = []
|
||||
curW = 0
|
||||
curIndent = nextIndent
|
||||
}
|
||||
|
||||
for (const tok of tokens) {
|
||||
if (tok.type === 'anchor') {
|
||||
// anchor opens at the current visible column (accounting for indent)
|
||||
if (tok.open) nextIndent = curIndent + curW
|
||||
else nextIndent = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (tok.type === 'sp') {
|
||||
// ignore leading soft spaces on a fresh line
|
||||
if (curW === 0) continue
|
||||
// hard wrap if the line is already at the right edge
|
||||
if (curIndent + curW + 1 > width) { flushLine(); continue }
|
||||
curTokens.push(tok)
|
||||
curW += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// word
|
||||
const tw = tok.w
|
||||
if (curIndent + curW + tw > width) {
|
||||
flushLine()
|
||||
// word too wide for the wrapped line: emit it on its own row (possibly clipped by terminal)
|
||||
if (curIndent + tw > width) {
|
||||
curTokens.push(tok)
|
||||
curW += tw
|
||||
flushLine()
|
||||
continue
|
||||
}
|
||||
}
|
||||
curTokens.push(tok)
|
||||
curW += tw
|
||||
}
|
||||
|
||||
if (curTokens.length > 0 || lines.length === 0) flushLine()
|
||||
|
||||
return lines.map((line, i) => formatLine(line, width, alignment, i === lines.length - 1))
|
||||
}
|
||||
|
||||
function formatLine(line, totalWidth, alignment, isLast) {
|
||||
if (line.tokens.length === 0) return ' '.repeat(totalWidth)
|
||||
|
||||
const indent = ' '.repeat(line.indent)
|
||||
const remaining = totalWidth - line.indent - line.contentW
|
||||
const pad = (n) => (n > 0) ? ' '.repeat(n) : ''
|
||||
const flatText = () => line.tokens.map(t => (t.type === 'sp') ? ' ' : t.text).join('')
|
||||
|
||||
if (alignment === 'c') {
|
||||
const left = remaining >> 1
|
||||
return indent + pad(left) + flatText() + pad(remaining - left)
|
||||
}
|
||||
if (alignment === 'r') return indent + pad(remaining) + flatText()
|
||||
if (alignment === 'l') return indent + flatText() + pad(remaining)
|
||||
|
||||
// justified: only expand spaces when there's slack and we're not on the
|
||||
// last (or single) wrapped line
|
||||
if (isLast || remaining <= 0) return indent + flatText() + pad(remaining)
|
||||
|
||||
const spaceCount = line.tokens.reduce((n, t) => n + (t.type === 'sp' ? 1 : 0), 0)
|
||||
if (spaceCount === 0) return indent + flatText() + pad(remaining)
|
||||
|
||||
const baseExtra = (remaining / spaceCount) | 0
|
||||
let leftover = remaining - baseExtra * spaceCount
|
||||
|
||||
let out = indent
|
||||
for (const tok of line.tokens) {
|
||||
if (tok.type === 'sp') {
|
||||
const extra = baseExtra + (leftover > 0 ? 1 : 0)
|
||||
if (leftover > 0) leftover -= 1
|
||||
out += ' '.repeat(1 + extra)
|
||||
} else {
|
||||
out += tok.text
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Process a single source line: peel a leading <c>/<r>/<l> alignment tag (if present),
|
||||
// strip its matching close tag, then tokenise + wrap.
|
||||
function typesetSourceLine(line, width) {
|
||||
if (line.length === 0) return [' '.repeat(width)]
|
||||
|
||||
let alignment = 'j' // justified default
|
||||
const startMatch = line.match(/^<([crl])>/i)
|
||||
if (startMatch) {
|
||||
alignment = startMatch[1].toLowerCase()
|
||||
line = line.slice(startMatch[0].length)
|
||||
const closeRe = new RegExp(`</${alignment}>$`, 'i')
|
||||
line = line.replace(closeRe, '')
|
||||
}
|
||||
|
||||
const tokens = tokenise(line)
|
||||
return wrapAndAlign(tokens, width, alignment)
|
||||
}
|
||||
|
||||
function typesetText(text, width) {
|
||||
text = expandEntities(text)
|
||||
const out = []
|
||||
for (const srcLine of text.split('\n')) {
|
||||
for (const outLine of typesetSourceLine(srcLine, width)) out.push(outLine)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function typeset(text, customWidth) {
|
||||
let typesetWidth = customWidth
|
||||
if (typesetWidth === undefined) typesetWidth = _G.TAUT.HELPMSG_WIDTH
|
||||
if (typesetWidth === undefined) {
|
||||
const currentPosX = con.getyx()[1] // 1-indexed
|
||||
typesetWidth = SCRW - currentPosX + 1
|
||||
}
|
||||
return typesetText(text, typesetWidth)
|
||||
// taut.js's popup uses (HELP_COL_TEXT on background) as the default colour pair.
|
||||
// The shared typesetter module owns the palette and the markup expander.
|
||||
function typeset(text) {
|
||||
return ts.typeset(text, _G.TAUT.HELPMSG_WIDTH)
|
||||
}
|
||||
|
||||
let helpMessages = [ // index: taut.js PANEL_NAMES
|
||||
[helpJam, helpTimeline, helpCommon, helpNotation].join('\n\n'),
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
||||
/* Timeline */[helpJam, helpTimeline, helpCommon, helpNotation].join(HRULE),
|
||||
/* Cues */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Patterns */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Samples */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Instruments */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Project */[helpProjectFlags, helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* File */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
]
|
||||
|
||||
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))
|
||||
help.typeset = typeset
|
||||
help.COL_TEXT = HELP_COL_TEXT
|
||||
help.COL_EMPH = HELP_COL_EMPH
|
||||
help.COL_TEXT = ts.COL_TEXT
|
||||
help.COL_EMPH = ts.COL_EMPH
|
||||
|
||||
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
/**
|
||||
* TAUT Sample Editor
|
||||
* Sub-program launched by taut.js when the Samples tab is active.
|
||||
* Rows 1-3 are owned by the parent; this program draws rows 4+.
|
||||
* TAUT Sample Editor (stub)
|
||||
* Sub-program launched from taut.js's Samples viewer. Rows 1-3 are owned by
|
||||
* the parent; this program draws rows 4+.
|
||||
*
|
||||
* exec_args[1] = path to .taud file
|
||||
* Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch.
|
||||
* exec_args:
|
||||
* [1] = path to .taud file
|
||||
* [2] = parent panel index (where to return)
|
||||
* [3] = sample index to preload (-1 if none)
|
||||
*
|
||||
* Sets _G.TAUT.UI.NEXTPANEL on return to request a panel switch back.
|
||||
*
|
||||
* Created by minjaesong on 2026-04-27
|
||||
* Stub editing UI added on 2026-05-26
|
||||
*/
|
||||
|
||||
const win = require("wintex")
|
||||
|
||||
const PANEL_COUNT = 7
|
||||
const MY_PANEL = 3 // VIEW_SAMPLES
|
||||
const PARENT_PANEL = (exec_args[2] !== undefined) ? (exec_args[2] | 0) : 3 // VIEW_SAMPLES
|
||||
const SAMPLE_IDX = (exec_args[3] !== undefined) ? (exec_args[3] | 0) : -1
|
||||
|
||||
const [SCRH, SCRW] = con.getmaxyx()
|
||||
const PANEL_Y = 4
|
||||
@@ -21,38 +26,122 @@ const PANEL_H = SCRH - PANEL_Y
|
||||
const colStatus = 253
|
||||
const colContent = 240
|
||||
const colHdr = 230
|
||||
const colEmph = 211
|
||||
const colDim = 246
|
||||
const colBack = 255
|
||||
const colSel = 41
|
||||
|
||||
function drawSampleEditContents(wo) {
|
||||
// Stub editor "fields": pretend toolbar. None of these write anything yet.
|
||||
const TOOLS = [
|
||||
{ key: 'L', label: 'Load .raw / .wav from disk' },
|
||||
{ key: 'S', label: 'Save current sample to disk' },
|
||||
{ key: 'D', label: 'Draw waveform freehand' },
|
||||
{ key: 'X', label: 'Crop / trim selection' },
|
||||
{ key: 'R', label: 'Resample' },
|
||||
{ key: 'V', label: 'Reverse' },
|
||||
{ key: 'N', label: 'Normalise to peak' },
|
||||
{ key: 'F', label: 'Fade in / out' },
|
||||
]
|
||||
|
||||
let toolCursor = 0
|
||||
|
||||
function drawSampleEditFrame() {
|
||||
for (let y = PANEL_Y; y < SCRH; y++) {
|
||||
con.move(y, 1)
|
||||
con.color_pair(colContent, 255)
|
||||
con.color_pair(colContent, colBack)
|
||||
print(' '.repeat(SCRW))
|
||||
}
|
||||
// Title
|
||||
con.move(PANEL_Y + 1, 3)
|
||||
con.color_pair(colHdr, 255)
|
||||
print('[ Sample Editor ]')
|
||||
con.move(PANEL_Y + 3, 3)
|
||||
con.color_pair(colStatus, 255)
|
||||
print('placeholder — not yet implemented')
|
||||
con.color_pair(colHdr, colBack); print('[ Sample Editor ] ')
|
||||
con.color_pair(colEmph, colBack); print('Sample ')
|
||||
con.color_pair(colStatus, colBack)
|
||||
if (SAMPLE_IDX >= 0) print('#' + (SAMPLE_IDX + 1).toString(16).toUpperCase().padStart(2, '0'))
|
||||
else print('(none)')
|
||||
|
||||
con.move(PANEL_Y + 2, 3)
|
||||
con.color_pair(colDim, colBack)
|
||||
print('stub editor — actions below are placeholders only.')
|
||||
}
|
||||
|
||||
function drawToolList() {
|
||||
const x = 5
|
||||
const y0 = PANEL_Y + 4
|
||||
con.move(y0, x)
|
||||
con.color_pair(colHdr, colBack); print('Editing actions')
|
||||
con.move(y0 + 1, x)
|
||||
con.color_pair(colDim, colBack); print('-'.repeat(16))
|
||||
|
||||
for (let i = 0; i < TOOLS.length; i++) {
|
||||
const y = y0 + 3 + i
|
||||
const t = TOOLS[i]
|
||||
const sel = (i === toolCursor)
|
||||
const back = sel ? colSel : colBack
|
||||
con.move(y, x)
|
||||
con.color_pair(colHdr, back); print(' ' + t.key + ' ')
|
||||
con.color_pair(colStatus, back); print(' ')
|
||||
con.color_pair(sel ? colEmph : colStatus, back)
|
||||
const w = SCRW - x - 6
|
||||
const lbl = t.label.length > w ? t.label.substring(0, w) : t.label.padEnd(w)
|
||||
print(lbl)
|
||||
}
|
||||
|
||||
// Drawing-area placeholder on the right
|
||||
const dx = 38
|
||||
const dy0 = PANEL_Y + 4
|
||||
const dw = SCRW - dx - 2
|
||||
const dh = SCRH - dy0 - 2
|
||||
con.move(dy0, dx)
|
||||
con.color_pair(colHdr, colBack); print('Waveform editor')
|
||||
con.move(dy0 + 1, dx)
|
||||
con.color_pair(colDim, colBack); print('-'.repeat(16))
|
||||
|
||||
// Empty drawing rectangle made of dots
|
||||
for (let r = 0; r < dh; r++) {
|
||||
con.move(dy0 + 3 + r, dx)
|
||||
con.color_pair(colDim, colBack)
|
||||
if (r === (dh >>> 1)) print('-'.repeat(dw)) // zero line
|
||||
else print(' '.repeat(dw))
|
||||
}
|
||||
con.move(dy0 + 3 + (dh >>> 1) + 1, dx)
|
||||
con.color_pair(colDim, colBack)
|
||||
print('(drawing surface — not yet implemented)')
|
||||
}
|
||||
|
||||
function drawHints() {
|
||||
con.move(SCRH, 1)
|
||||
con.color_pair(colStatus, 255)
|
||||
con.color_pair(colStatus, colBack)
|
||||
print(' '.repeat(SCRW - 1))
|
||||
con.move(SCRH, 1)
|
||||
con.color_pair(colHdr, 255); print('Tab ')
|
||||
con.color_pair(colStatus, 255); print('Panel')
|
||||
con.color_pair(colHdr, colBack); print('28u29u ')
|
||||
con.color_pair(colStatus, colBack); print('Tool ')
|
||||
con.color_pair(colHdr, colBack); print('Enter ')
|
||||
con.color_pair(colStatus, colBack); print('Apply ')
|
||||
con.color_pair(colHdr, colBack); print('Esc/Tab ')
|
||||
con.color_pair(colStatus, colBack); print('Back to viewer')
|
||||
}
|
||||
|
||||
function flashAction(idx) {
|
||||
const t = TOOLS[idx]
|
||||
if (!t) return
|
||||
con.move(SCRH - 2, 5)
|
||||
con.color_pair(colEmph, colBack)
|
||||
print(('Action: ' + t.label + ' (stub, no-op)').padEnd(SCRW - 8))
|
||||
}
|
||||
|
||||
function sampleEditInput(wo, event) {
|
||||
// placeholder — no interaction yet
|
||||
// wintex panel input — wired up but the loop below handles keys directly.
|
||||
}
|
||||
|
||||
const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, sampleEditInput, drawSampleEditContents, undefined, ()=>{})
|
||||
function drawAll() {
|
||||
drawSampleEditFrame()
|
||||
drawToolList()
|
||||
drawHints()
|
||||
}
|
||||
|
||||
const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, sampleEditInput, drawAll, undefined, ()=>{})
|
||||
|
||||
panel.drawContents()
|
||||
drawHints()
|
||||
|
||||
let done = false
|
||||
while (!done) {
|
||||
@@ -60,17 +149,32 @@ while (!done) {
|
||||
if (event[0] !== 'key_down') return
|
||||
const keysym = event[1]
|
||||
const keyJustHit = (1 == event[2])
|
||||
const shiftDown = (event.includes(59) || event.includes(60))
|
||||
|
||||
if (!keyJustHit) return
|
||||
|
||||
if (keysym === '<TAB>') {
|
||||
_G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
|
||||
if (keysym === '<ESCAPE>' || keysym === '<TAB>') {
|
||||
_G.TAUT.UI.NEXTPANEL = PARENT_PANEL
|
||||
done = true
|
||||
return
|
||||
}
|
||||
|
||||
panel.processInput(event)
|
||||
if (keysym === '<UP>') { if (toolCursor > 0) toolCursor--; drawToolList(); return }
|
||||
if (keysym === '<DOWN>') { if (toolCursor < TOOLS.length-1) toolCursor++; drawToolList(); return }
|
||||
|
||||
if (keysym === '\n') {
|
||||
flashAction(toolCursor)
|
||||
return
|
||||
}
|
||||
|
||||
// Direct key shortcuts
|
||||
for (let i = 0; i < TOOLS.length; i++) {
|
||||
if (keysym === TOOLS[i].key.toLowerCase() || keysym === TOOLS[i].key) {
|
||||
toolCursor = i
|
||||
drawToolList()
|
||||
flashAction(i)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
11
assets/disk0/tvdos/hopper/getopt.hop.per
Normal file
11
assets/disk0/tvdos/hopper/getopt.hop.per
Normal file
@@ -0,0 +1,11 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:getopt
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:getopt;
|
||||
HopperRequires:
|
||||
ProperName:getopt.js
|
||||
ProperAuthor:David Pacheco
|
||||
ProperDescription:node.js implementation of POSIX getopt() (and then some)
|
||||
Licence:MIT
|
||||
SystemPackagePath:/tvdos/include/getopt.mjs
|
||||
12
assets/disk0/tvdos/hopper/libfs.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libfs.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libfs
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libfs;
|
||||
HopperRequires:tvdos 1.*;
|
||||
ProperName:LibFS
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:NodeJS-compatible Filesystem module for TVDOS
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/fs.mjs
|
||||
12
assets/disk0/tvdos/hopper/libgl.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libgl.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libgl
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libgl;
|
||||
HopperRequires:
|
||||
ProperName:LibGL
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TVDOS Graphics Library
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/gl.mjs
|
||||
12
assets/disk0/tvdos/hopper/libpcm.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libpcm.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libpcm
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libpcm;
|
||||
HopperRequires:
|
||||
ProperName:LibPCM
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:PCM decoder for TSVM
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/pcm.mjs
|
||||
12
assets/disk0/tvdos/hopper/libpsg.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libpsg.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libpsg
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libpsg;
|
||||
HopperRequires:
|
||||
ProperName:LibPSG
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Programmable sound generator library for TSVM
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/psg.mjs
|
||||
12
assets/disk0/tvdos/hopper/libseqread.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libseqread.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libseqread
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libseqread;
|
||||
HopperRequires:tvdos 1.*;
|
||||
ProperName:LibSeqread
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Sequentially read files from disk drive
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/seqread.mjs;/tvdos/include/seqreadtape.mjs
|
||||
12
assets/disk0/tvdos/hopper/libtaud.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libtaud.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libtaud
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libtaud;
|
||||
HopperRequires:tvdos 1.*;
|
||||
ProperName:LibTaud
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Helper functions for interaction between Taud format and TSVM Tracker
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/taud.mjs
|
||||
12
assets/disk0/tvdos/hopper/libterranbasic.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libterranbasic.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libterranbasic
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libterranbasic;
|
||||
HopperRequires:
|
||||
ProperName:LibTerranBasic
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Terran BASIC runtime helper for compiled programs
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/tbas.mjs
|
||||
12
assets/disk0/tvdos/hopper/microtone.hop.per
Normal file
12
assets/disk0/tvdos/hopper/microtone.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:microtone
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:microtone;
|
||||
HopperRequires:tvdos 1.*;wintex 1.*;libtaud 1.*;libgl 1.*
|
||||
ProperName:Microtone
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Microtonal tracker for TSVM
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/bin/microtone.alias;/tvdos/bin/taut*
|
||||
17
assets/disk0/tvdos/hopper/mirrors.list
Normal file
17
assets/disk0/tvdos/hopper/mirrors.list
Normal file
@@ -0,0 +1,17 @@
|
||||
# Hopper Mirror List
|
||||
#
|
||||
# One mirror per non-empty, non-comment line.
|
||||
# Each entry is the remote URL prefix from which Hopper can fetch
|
||||
# <prefix>mirror_manifest
|
||||
# <prefix>filelist
|
||||
# <prefix><package>.hop.per (one per row of filelist)
|
||||
#
|
||||
# `mirror_manifest` declares HopperMirrorName, HopperMirrorMaintainer
|
||||
# and HopperMirrorRemotePrefix; `filelist` is CSV of
|
||||
# packagename,version,hoppermanifest-filename
|
||||
#
|
||||
# Lines starting with `#` and empty lines are ignored.
|
||||
# A trailing slash on the prefix is optional; Hopper will add one
|
||||
# if missing.
|
||||
|
||||
https://raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/
|
||||
12
assets/disk0/tvdos/hopper/textedit.hop.per
Normal file
12
assets/disk0/tvdos/hopper/textedit.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:textedit
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:edit;
|
||||
HopperRequires:tvdos 1.*
|
||||
ProperName:edit.js
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TVDOS default text editor
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/bin/edit.js
|
||||
12
assets/disk0/tvdos/hopper/tvdos.hop.per
Normal file
12
assets/disk0/tvdos/hopper/tvdos.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:tvdos
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:tvdos;
|
||||
HopperRequires:
|
||||
ProperName:TVDOS
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TSVM Disk Operating System
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/TVDOS.SYS;/tvdos/hyve.SYS;/tvdos/HSDPADRV.SYS;/tvdos/bin/command.js;/tvdos/sbin/sysctl.js;/tvdos/include/font.mjs;/tvdos/include/keysym.mjs;/tvdos/include/mload.mjs;/tvdos/include/playgui.mjs;/tvdos/include/typesetter.mjs
|
||||
12
assets/disk0/tvdos/hopper/wintex.hop.per
Normal file
12
assets/disk0/tvdos/hopper/wintex.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:wintex
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:wintex;
|
||||
HopperRequires:
|
||||
ProperName:WinTex
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TUI window management and renderer
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/wintex.mjs
|
||||
12
assets/disk0/tvdos/hopper/zfm.hop.per
Normal file
12
assets/disk0/tvdos/hopper/zfm.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:zfm
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:zfm;
|
||||
HopperRequires:tvdos 1.*;wintex 1.*
|
||||
ProperName:ZFM
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Z File Manager - Dual-panel file manager for TVDOS
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/bin/zfm*
|
||||
1129
assets/disk0/tvdos/include/fs.mjs
Normal file
1129
assets/disk0/tvdos/include/fs.mjs
Normal file
File diff suppressed because it is too large
Load Diff
171
assets/disk0/tvdos/include/lfs.mjs
Normal file
171
assets/disk0/tvdos/include/lfs.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* lfs.mjs — programmatic extractor for TVDOS Linear File Strip archives.
|
||||
*
|
||||
* let lfs = require("A:/tvdos/include/lfs.mjs")
|
||||
*
|
||||
* // Pull one entry out:
|
||||
* let fd = lfs.extractOne("A:/path/archive.lfs", "wanted.bin")
|
||||
* // → file descriptor for $:/TMP/<random>/wanted.bin
|
||||
*
|
||||
* // Unpack the whole archive:
|
||||
* let dir = lfs.extractAll("A:/path/archive.lfs")
|
||||
* // → directory descriptor for $:/TMP/<random>/
|
||||
*
|
||||
* Both functions accept an `autoDecompress` boolean (default true). When
|
||||
* a payload's first four bytes match the gzip (1F 8B 08 xx) or zstd
|
||||
* (28 B5 2F FD) magic, the payload is inflated through gzip.decomp()
|
||||
* before being written. The check is done on the payload bytes — the
|
||||
* archived filename is irrelevant.
|
||||
*
|
||||
* Both functions require a relative-path archive (one produced by
|
||||
* `lfs -c -r`); fully qualified archives carry drive letters that would
|
||||
* not make sense rerooted under $:/TMP.
|
||||
*/
|
||||
|
||||
const TMP_ROOT = "$:/TMP"
|
||||
const HASH_ALPHABET = "YBNDRFG8EJKMCPQXOTLVWIS2A345H769"
|
||||
const HASH_LEN = 32
|
||||
const LFS_HEADER = "TVDOSLFS\x01"
|
||||
const LFS_HEADER_LEN = 16
|
||||
const LFS_FLAG_RELATIVE = 0x01
|
||||
|
||||
|
||||
function _makeHash(n) {
|
||||
let s = ""
|
||||
const m = HASH_ALPHABET.length
|
||||
for (let i = 0; i < n; i++) s += HASH_ALPHABET[Math.floor(Math.random() * m)]
|
||||
return s
|
||||
}
|
||||
|
||||
function _isCompressed(s) {
|
||||
if (s.length < 4) return false
|
||||
const b0 = s.charCodeAt(0), b1 = s.charCodeAt(1)
|
||||
const b2 = s.charCodeAt(2), b3 = s.charCodeAt(3)
|
||||
if (b0 === 0x1f && b1 === 0x8b && b2 === 0x08) return true // gzip
|
||||
if (b0 === 0x28 && b1 === 0xb5 && b2 === 0x2f && b3 === 0xfd) return true // zstd
|
||||
return false
|
||||
}
|
||||
|
||||
function _decompress(payload) {
|
||||
// gzip.decomp transparently handles both gzip and zstd; returns Java byte[].
|
||||
return btostr(gzip.decomp(payload))
|
||||
}
|
||||
|
||||
function _readArchive(lfsPath) {
|
||||
const fd = files.open(lfsPath)
|
||||
if (!fd.exists) throw new Error("LFS archive not found: " + lfsPath)
|
||||
if (fd.isDirectory) throw new Error("LFS archive is a directory: " + lfsPath)
|
||||
|
||||
const bytes = fd.sread()
|
||||
try { fd.close() } catch (_) {}
|
||||
|
||||
if (bytes.substring(0, LFS_HEADER.length) !== LFS_HEADER)
|
||||
throw new Error("Not an LFS archive: " + lfsPath)
|
||||
|
||||
const flags = bytes.charCodeAt(11)
|
||||
if ((flags & LFS_FLAG_RELATIVE) === 0)
|
||||
throw new Error("LFS archive does not use relative paths: " + lfsPath)
|
||||
|
||||
return bytes
|
||||
}
|
||||
|
||||
function _allocTmpDir() {
|
||||
const path = TMP_ROOT + "/" + _makeHash(HASH_LEN)
|
||||
const dir = files.open(path)
|
||||
dir.mkDir()
|
||||
return { fd: dir, path: path }
|
||||
}
|
||||
|
||||
function _normPath(p) {
|
||||
return p.replace(/\//g, "\\")
|
||||
}
|
||||
|
||||
function _writeFile(destDirPath, archivePath, payload) {
|
||||
const parts = _normPath(archivePath).split("\\").filter(p => p.length > 0)
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const leaf = parts.pop()
|
||||
let curPath = destDirPath
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
curPath = curPath + "/" + parts[i]
|
||||
const cur = files.open(curPath)
|
||||
if (!cur.exists) cur.mkDir()
|
||||
}
|
||||
const outfile = files.open(curPath + "/" + leaf)
|
||||
if (!outfile.exists) outfile.mkFile()
|
||||
outfile.swrite(payload)
|
||||
return outfile
|
||||
}
|
||||
|
||||
|
||||
function extractOne(lfsPath, filename, autoDecompress) {
|
||||
if (autoDecompress === undefined) autoDecompress = true
|
||||
if (filename === undefined || filename === null || filename === "")
|
||||
throw new Error("filename is required")
|
||||
|
||||
const bytes = _readArchive(lfsPath)
|
||||
const needle = _normPath(filename)
|
||||
|
||||
let curs = LFS_HEADER_LEN
|
||||
while (curs < bytes.length) {
|
||||
const fileType = bytes.charCodeAt(curs)
|
||||
const pathlen = (bytes.charCodeAt(curs+1) << 8) | bytes.charCodeAt(curs+2)
|
||||
curs += 3
|
||||
const path = bytes.substring(curs, curs + pathlen)
|
||||
curs += pathlen
|
||||
const filelen = (bytes.charCodeAt(curs) << 24)
|
||||
| (bytes.charCodeAt(curs+1) << 16)
|
||||
| (bytes.charCodeAt(curs+2) << 8)
|
||||
| bytes.charCodeAt(curs+3)
|
||||
curs += 4
|
||||
|
||||
if (_normPath(path) === needle) {
|
||||
let payload = bytes.substring(curs, curs + filelen)
|
||||
if (autoDecompress && _isCompressed(payload)) payload = _decompress(payload)
|
||||
|
||||
const dest = _allocTmpDir()
|
||||
const leaf = needle.split("\\").pop()
|
||||
const outfile = files.open(dest.path + "/" + leaf)
|
||||
if (!outfile.exists) outfile.mkFile()
|
||||
outfile.swrite(payload)
|
||||
return outfile
|
||||
}
|
||||
|
||||
curs += filelen
|
||||
}
|
||||
|
||||
throw new Error("File not found in archive: " + filename)
|
||||
}
|
||||
|
||||
|
||||
function extractAll(lfsPath, autoDecompress) {
|
||||
if (autoDecompress === undefined) autoDecompress = true
|
||||
|
||||
const bytes = _readArchive(lfsPath)
|
||||
const dest = _allocTmpDir()
|
||||
|
||||
let curs = LFS_HEADER_LEN
|
||||
while (curs < bytes.length) {
|
||||
const fileType = bytes.charCodeAt(curs)
|
||||
const pathlen = (bytes.charCodeAt(curs+1) << 8) | bytes.charCodeAt(curs+2)
|
||||
curs += 3
|
||||
const path = bytes.substring(curs, curs + pathlen)
|
||||
curs += pathlen
|
||||
const filelen = (bytes.charCodeAt(curs) << 24)
|
||||
| (bytes.charCodeAt(curs+1) << 16)
|
||||
| (bytes.charCodeAt(curs+2) << 8)
|
||||
| bytes.charCodeAt(curs+3)
|
||||
curs += 4
|
||||
|
||||
let payload = bytes.substring(curs, curs + filelen)
|
||||
if (autoDecompress && _isCompressed(payload)) payload = _decompress(payload)
|
||||
_writeFile(dest.path, path, payload)
|
||||
|
||||
curs += filelen
|
||||
}
|
||||
|
||||
return dest.fd
|
||||
}
|
||||
|
||||
|
||||
exports = { extractOne, extractAll }
|
||||
123
assets/disk0/tvdos/include/net.mjs
Normal file
123
assets/disk0/tvdos/include/net.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* net.mjs — Internet text-fetch helper for TVDOS
|
||||
*
|
||||
* Wraps the HttpModem peripheral (driven by `_TVDOS.DRV.FS.NET`, see
|
||||
* TVDOS.SYS:1001-1034) behind a small, regular-URL-friendly API. The
|
||||
* helper looks up whichever drive letter the boot probe assigned to the
|
||||
* HTTP modem and translates ordinary URLs (`https://host/path`) into the
|
||||
* scheme-without-double-slash form (`https:host/path`) that the modem
|
||||
* expects on the wire.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* let net = require("A:/tvdos/include/net.mjs")
|
||||
*
|
||||
* if (!net.isAvailable())
|
||||
* printerrln("No HTTP modem attached")
|
||||
*
|
||||
* let body = net.fetchText("https://example.com/index.html")
|
||||
* if (body === null) printerrln("Fetch failed")
|
||||
* else println(body)
|
||||
*/
|
||||
|
||||
|
||||
let _cachedDrive = null
|
||||
|
||||
/** Scan TVDOS drive table for an HTTP-typed device. Returns the drive
|
||||
* letter (e.g. "B") or null. */
|
||||
function _findHttpDrive() {
|
||||
if (typeof _TVDOS === 'undefined' || !_TVDOS.DRIVEINFO) return null
|
||||
if (_cachedDrive !== null && _TVDOS.DRIVEINFO[_cachedDrive] &&
|
||||
_TVDOS.DRIVEINFO[_cachedDrive].type === 'HTTP')
|
||||
return _cachedDrive
|
||||
|
||||
for (let letter in _TVDOS.DRIVEINFO) {
|
||||
let info = _TVDOS.DRIVEINFO[letter]
|
||||
if (info && info.type === 'HTTP') {
|
||||
_cachedDrive = letter
|
||||
return letter
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Convert a regular URL into the form the HTTP modem accepts:
|
||||
* - strip the `//` between scheme and authority
|
||||
* - drop any URL fragment
|
||||
* - assume `https` when no scheme is provided
|
||||
*/
|
||||
function _normaliseUrl(url) {
|
||||
if (typeof url !== 'string')
|
||||
throw new TypeError("url must be a string")
|
||||
let s = url.trim()
|
||||
if (s.length === 0) throw new Error("url is empty")
|
||||
|
||||
// Drop fragment — the modem speaks to the server, # is client-side.
|
||||
let hash = s.indexOf('#')
|
||||
if (hash >= 0) s = s.substring(0, hash)
|
||||
|
||||
// scheme://host/path → scheme:host/path
|
||||
let m = s.match(/^([a-zA-Z][a-zA-Z0-9+.\-]*):\/\/(.*)$/)
|
||||
if (m) return m[1].toLowerCase() + ':' + m[2]
|
||||
|
||||
// Already in scheme:host/path form (the modem's native shape)
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.\-]*:[^/]/.test(s)) return s
|
||||
|
||||
// No scheme — default to https
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(s))
|
||||
return 'https:' + s.replace(/^\/\//, '')
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
let net = {}
|
||||
|
||||
/** Returns the drive letter currently bound to the HTTP modem, or null
|
||||
* when no such device is attached. */
|
||||
net.getHttpDrive = function () {
|
||||
return _findHttpDrive()
|
||||
}
|
||||
|
||||
/** True iff an HTTP modem is reachable through TVDOS. */
|
||||
net.isAvailable = function () {
|
||||
return _findHttpDrive() !== null
|
||||
}
|
||||
|
||||
/** Translate a URL into the `<drive>:\<modem-url>` form that
|
||||
* `files.open()` would route through `_TVDOS.DRV.FS.NET`. Useful when
|
||||
* another component wants the descriptor directly. Throws if no HTTP
|
||||
* modem is attached. */
|
||||
net.toModemPath = function (url) {
|
||||
let drive = _findHttpDrive()
|
||||
if (drive === null) throw new Error("No HTTP modem device is attached")
|
||||
return drive + ':\\' + _normaliseUrl(url)
|
||||
}
|
||||
|
||||
/** Open a TVDOS file descriptor backed by the HTTP modem for the given
|
||||
* URL. The descriptor's sread()/bread() trigger the actual fetch.
|
||||
* Throws if no HTTP modem is attached. */
|
||||
net.open = function (url) {
|
||||
return files.open(net.toModemPath(url))
|
||||
}
|
||||
|
||||
/** Fetch the body of `url` as a string. Returns the response text on
|
||||
* success, or null when the modem reports a non-zero status (bad URL,
|
||||
* I/O error, etc.). Throws if no HTTP modem is attached. */
|
||||
net.fetchText = function (url) {
|
||||
let fd = net.open(url)
|
||||
let text = fd.sread()
|
||||
try { fd.close() } catch (_) {}
|
||||
return (text === undefined) ? null : text
|
||||
}
|
||||
|
||||
/** Like fetchText, but throws an Error instead of returning null on
|
||||
* fetch failure. */
|
||||
net.fetchTextOrThrow = function (url) {
|
||||
let body = net.fetchText(url)
|
||||
if (body === null) throw new Error("Failed to fetch URL: " + url)
|
||||
return body
|
||||
}
|
||||
|
||||
|
||||
exports = net
|
||||
@@ -281,9 +281,997 @@ function printTopBar(status, moreInfo) {
|
||||
con.move(1, 1)
|
||||
}
|
||||
|
||||
// ── Audio player visualiser ─────────────────────────────────────────────────
|
||||
// Shared by playwav/playmp2/playpcm/playtad. Design follows
|
||||
// `assets/playwav_visualiser_design_2_for_tsvm.md`:
|
||||
// * 3-row ASCII wavescope (mid signal envelope) on rows 3..5
|
||||
// * 22-col progress dashes on the right side of the song-title row
|
||||
// * 24-row XY-scope + wavelet-modulated persistence visualiser on rows 7..30
|
||||
// * stereo energy bar on row 31
|
||||
//
|
||||
// The visualiser fuses two displays the design doc calls complementary:
|
||||
// * XY-scope geometry (rotated 45° so L plots along the `\` diagonal and R
|
||||
// along `/`) gives spatial motion and stereo image.
|
||||
// * Haar wavelet features (transient / noise / sustain energies) steer the
|
||||
// beam's behaviour — transients evaporate it and emit sparks, sustained
|
||||
// content lets trails breathe longer, mid noise jitters the beam.
|
||||
//
|
||||
// The wavelet is therefore a *modulator*, not a renderer. No FFT, no pitch
|
||||
// tracking, no per-frame allocation in the hot loop.
|
||||
|
||||
const AG_COLS = 80
|
||||
const AG_ROWS = 32
|
||||
const AG_COL_INSIDE_L = 2
|
||||
const AG_COL_INSIDE_R = 79
|
||||
const AG_LANE_W = 78
|
||||
|
||||
const AG_ROW_TOP_BORDER = 1
|
||||
const AG_ROW_TITLE = 2
|
||||
const AG_ROW_WAVE_TOP = 3
|
||||
const AG_ROW_WAVE_BOT = 5 // 3-row wavescope
|
||||
const AG_ROW_VIS_SEP = 6
|
||||
const AG_ROW_VIS_TOP = 7
|
||||
const AG_ROW_VIS_BOT = 30 // 24-row wavelet visualiser
|
||||
const AG_ROW_STEREO = 31
|
||||
const AG_ROW_BOT_BORDER = 32
|
||||
|
||||
const AG_VIS_H = AG_ROW_VIS_BOT - AG_ROW_VIS_TOP + 1 // 24
|
||||
const AG_VIS_W = AG_LANE_W // 78
|
||||
|
||||
// Palette (TSVM 256-colour indices)
|
||||
const AG_COL_BG = 0
|
||||
const AG_COL_BORDER = 250
|
||||
const AG_COL_LABEL = 220
|
||||
const AG_COL_DIM = 235
|
||||
const AG_COL_TITLE = 230
|
||||
const AG_COL_VALUE = 254
|
||||
const AG_COL_PROG_ON = 226 // bright yellow (matches Taud)
|
||||
|
||||
// Box-drawing constants (CP437)
|
||||
const AG_BX_TL = 0xC9, AG_BX_TR = 0xBB, AG_BX_BL = 0xC8, AG_BX_BR = 0xBC
|
||||
const AG_BX_V = 0xBA, AG_BX_H = 0xCD
|
||||
const AG_SEP_L = 0xC7, AG_SEP_R = 0xB6
|
||||
|
||||
// Density stairs for visualiser + stereo bar
|
||||
const AG_STAIRS = [0x20, 0xB0, 0xB1, 0xB2, 0xDB] // ' ', ░, ▒, ▓, █
|
||||
|
||||
// Electron-beam colour ramp. Index 0 = silent (background), last = freshly
|
||||
// drawn beam. Amber-on-black mimics analog vector-scope CRT phosphor — the
|
||||
// glyph shape carries the spatial information, the colour ramp carries age.
|
||||
const AG_BEAM_PAL = [AG_COL_BG, 94, 130, 166, 220]
|
||||
|
||||
// Five wavelet levels (Haar decomp). These are used only as modulators —
|
||||
// they never get rendered as bars. Indexing:
|
||||
// AG_WL_TRANSIENT — top-octave detail (8 kHz..16 kHz at 32 kHz Fs).
|
||||
// Spikes on percussion attacks, vocal consonants, cymbals.
|
||||
// AG_WL_NOISE — upper-mid detail (4..8 kHz). Drives beam jitter.
|
||||
// AG_WL_BODY — mid detail (2..4 kHz).
|
||||
// AG_WL_TONAL — lower-mid detail (1..2 kHz).
|
||||
// AG_WL_BASS — low detail (0.5..1 kHz). Slows the decay (sustain).
|
||||
const AG_N_BANDS = 5
|
||||
const AG_WL_TRANSIENT = 0
|
||||
const AG_WL_NOISE = 1
|
||||
const AG_WL_BODY = 2
|
||||
const AG_WL_TONAL = 3
|
||||
const AG_WL_BASS = 4
|
||||
|
||||
// Stereo bar colour ramp (5 levels) — uses the tonal blue gradient so the
|
||||
// stereo strip reads as the "ground" beneath the wavelet cloud.
|
||||
const AG_STEREO_COL = [AG_COL_DIM, 17, 33, 75, 117]
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// All state lives in module scope so a player just does:
|
||||
// const gui = require('playgui')
|
||||
// gui.audioInit({...})
|
||||
// while (...) { ...; gui.audioFeedPcm(ptr, n); gui.audioRender(); }
|
||||
// gui.audioClose()
|
||||
//
|
||||
// Multiple concurrent players in one process are not supported — but TVDOS
|
||||
// only runs one foreground command at a time, so that's fine.
|
||||
|
||||
const AG_SNAPSHOT_N = 1024 // power of 2; covers ~32 ms at 32 kHz
|
||||
const ag_snapL = new Float32Array(AG_SNAPSHOT_N)
|
||||
const ag_snapR = new Float32Array(AG_SNAPSHOT_N)
|
||||
|
||||
const AG_WORK_N = AG_SNAPSHOT_N // scratch buffers for Haar pyramid
|
||||
const ag_workMid = new Float32Array(AG_WORK_N)
|
||||
const ag_workTmp = new Float32Array(AG_WORK_N >> 1)
|
||||
const ag_bandEnergy = new Float32Array(AG_N_BANDS)
|
||||
|
||||
// Sub-500 Hz residual — drops out of the wavelet modulator set on purpose,
|
||||
// but we keep its RMS around to drive the bass mark.
|
||||
let ag_bassEnergy = 0
|
||||
|
||||
// Persistence buffer — float intensity per cell, plus the glyph last written
|
||||
// there. Decay shrinks intensity each frame; new beam samples overwrite the
|
||||
// glyph and bump intensity.
|
||||
const ag_persist = new Float32Array(AG_VIS_H * AG_VIS_W)
|
||||
const ag_persistGlyph = new Int16Array(AG_VIS_H * AG_VIS_W)
|
||||
|
||||
// Skip-redraw cache — only emit a cell when its glyph or colour changes.
|
||||
const ag_cellGlyph = new Int16Array(AG_VIS_H * AG_VIS_W).fill(-1)
|
||||
const ag_cellFg = new Int16Array(AG_VIS_H * AG_VIS_W).fill(-1)
|
||||
const ag_waveGlyph = new Int16Array(AG_LANE_W * 3).fill(-1)
|
||||
const ag_stereoGlyph = new Int16Array(AG_LANE_W).fill(-1)
|
||||
const ag_stereoFg = new Int16Array(AG_LANE_W).fill(-1)
|
||||
let ag_lastBassFg = -1
|
||||
|
||||
// Render rate-limiter — playmp2 spins ~32 Hz, playtad ~1 Hz, playwav ~100 Hz
|
||||
// at decode time. Clamp visual refresh to 20 Hz so each caller can spam
|
||||
// audioRender() without worrying about pacing.
|
||||
let ag_lastRenderNs = 0
|
||||
const AG_RENDER_INTERVAL_NS = 50 * 1000 * 1000 // 50 ms
|
||||
|
||||
// Latest progress fraction so we redraw the bar only when it changes.
|
||||
let ag_lastProgressIdx = -1
|
||||
let ag_lastTimeStr = ''
|
||||
|
||||
// Init params held for re-use during render.
|
||||
let ag_initParams = null
|
||||
|
||||
function ag_color(fg, bg) { con.color_pair(fg, bg) }
|
||||
function ag_mvprn(row, col, ch) { con.mvaddch(row, col, ch) }
|
||||
function ag_mvtext(row, col, s) { con.move(row, col); print(s) }
|
||||
|
||||
function ag_pad(n, w) {
|
||||
let s = '' + n
|
||||
while (s.length < w) s = ' ' + s
|
||||
return s
|
||||
}
|
||||
|
||||
function ag_secToReadable(n) {
|
||||
const mins = ('' + ((n / 60) | 0)).padStart(2, '0')
|
||||
const secs = ('' + (n % 60)).padStart(2, '0')
|
||||
return mins + ':' + secs
|
||||
}
|
||||
|
||||
function ag_drawSeparator(row, label) {
|
||||
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||
ag_mvprn(row, 1, AG_SEP_L)
|
||||
for (let x = 2; x < AG_COLS; x++) ag_mvprn(row, x, AG_BX_H)
|
||||
ag_mvprn(row, AG_COLS, AG_SEP_R)
|
||||
if (label) {
|
||||
ag_color(AG_COL_LABEL, AG_COL_BG)
|
||||
ag_mvtext(row, 5, ' ' + label + ' ')
|
||||
}
|
||||
}
|
||||
|
||||
function ag_drawFrame() {
|
||||
// Top border with embedded format tag.
|
||||
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_TOP_BORDER, 1, AG_BX_TL)
|
||||
for (let x = 2; x < AG_COLS; x++) ag_mvprn(AG_ROW_TOP_BORDER, x, AG_BX_H)
|
||||
ag_mvprn(AG_ROW_TOP_BORDER, AG_COLS, AG_BX_TR)
|
||||
if (ag_initParams.tag) {
|
||||
ag_color(AG_COL_LABEL, AG_COL_BG)
|
||||
ag_mvtext(AG_ROW_TOP_BORDER, 4, ' ' + ag_initParams.tag + ' ')
|
||||
}
|
||||
|
||||
// Bottom border with exit hint.
|
||||
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_BOT_BORDER, 1, AG_BX_BL)
|
||||
for (let x = 2; x < AG_COLS; x++) ag_mvprn(AG_ROW_BOT_BORDER, x, AG_BX_H)
|
||||
ag_mvprn(AG_ROW_BOT_BORDER, AG_COLS, AG_BX_BR)
|
||||
ag_color(AG_COL_DIM, AG_COL_BG)
|
||||
ag_mvtext(AG_ROW_BOT_BORDER, 4, ' Hold BkSp to exit ')
|
||||
|
||||
// Side bars.
|
||||
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||
for (let r = 2; r < AG_ROWS; r++) {
|
||||
ag_mvprn(r, 1, AG_BX_V)
|
||||
ag_mvprn(r, AG_COLS, AG_BX_V)
|
||||
}
|
||||
|
||||
// Inner separator over the visualiser canvas. The wavescope strip sits
|
||||
// flush against the title row — no separator there.
|
||||
ag_drawSeparator(AG_ROW_VIS_SEP, 'VISUALS')
|
||||
}
|
||||
|
||||
function ag_clearInside(row) {
|
||||
ag_color(AG_COL_DIM, AG_COL_BG)
|
||||
con.move(row, AG_COL_INSIDE_L)
|
||||
print(' '.repeat(AG_LANE_W))
|
||||
}
|
||||
|
||||
function ag_drawTitle() {
|
||||
ag_clearInside(AG_ROW_TITLE)
|
||||
let title = ag_initParams.title || ''
|
||||
// Reserve 24 cols on the right for time string + progress bar.
|
||||
if (title.length > AG_LANE_W - 26) title = title.substring(0, AG_LANE_W - 29) + '...'
|
||||
ag_color(AG_COL_TITLE, AG_COL_BG)
|
||||
ag_mvtext(AG_ROW_TITLE, AG_COL_INSIDE_L + 1, title)
|
||||
}
|
||||
|
||||
// Progress: time string + 22-wide dashes ramp (matches playtaud). Called by
|
||||
// the player via audioSetProgress; redraws only when something changed.
|
||||
function ag_drawProgress(progress, elapsedSec, totalSec) {
|
||||
const barW = 22
|
||||
const bx0 = AG_COL_INSIDE_R - barW
|
||||
const filled = Math.round(progress * barW)
|
||||
|
||||
const timeStr = ag_secToReadable(elapsedSec) + '/' + ag_secToReadable(totalSec)
|
||||
if (timeStr !== ag_lastTimeStr) {
|
||||
ag_lastTimeStr = timeStr
|
||||
ag_color(AG_COL_VALUE, AG_COL_BG)
|
||||
ag_mvtext(AG_ROW_TITLE, bx0 - timeStr.length - 1, timeStr)
|
||||
}
|
||||
|
||||
if (filled === ag_lastProgressIdx) return
|
||||
ag_lastProgressIdx = filled
|
||||
|
||||
for (let i = 0; i < barW; i++) {
|
||||
const lit = i < filled
|
||||
ag_color(lit ? AG_COL_PROG_ON : AG_COL_DIM, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_TITLE, bx0 + i, lit ? 0x7C /*│*/ : 0x2E /*.*/)
|
||||
}
|
||||
}
|
||||
|
||||
// ── PCM ingestion ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// feedPcm copies the most recent SNAPSHOT_N samples from a PCMu8-stereo-
|
||||
// interleaved buffer into our snapshot. `ptr` can be a positive heap address
|
||||
// (LPCM/ADPCM decoded buffer, raw PCM) or a negative peripheral address (TAD
|
||||
// decoded buffer, MP2 mediaDecodedBin) — TSVM peripheral memory grows toward
|
||||
// 0, so reads use a signed step `vec`.
|
||||
|
||||
function audioFeedPcm(ptr, sampleCount) {
|
||||
if (!sampleCount) return
|
||||
const vec = ptr >= 0 ? 1 : -1
|
||||
const inv128 = 1 / 128
|
||||
|
||||
if (sampleCount >= AG_SNAPSHOT_N) {
|
||||
// Take last AG_SNAPSHOT_N samples — discard the rest.
|
||||
const start = sampleCount - AG_SNAPSHOT_N
|
||||
for (let i = 0; i < AG_SNAPSHOT_N; i++) {
|
||||
const off = (start + i) * 2 * vec
|
||||
ag_snapL[i] = ((sys.peek(ptr + off) & 0xFF) - 128) * inv128
|
||||
ag_snapR[i] = ((sys.peek(ptr + off + vec) & 0xFF) - 128) * inv128
|
||||
}
|
||||
} else {
|
||||
// Shift snapshot left by `sampleCount` and append all new samples.
|
||||
const shift = sampleCount
|
||||
const keep = AG_SNAPSHOT_N - shift
|
||||
for (let i = 0; i < keep; i++) {
|
||||
ag_snapL[i] = ag_snapL[i + shift]
|
||||
ag_snapR[i] = ag_snapR[i + shift]
|
||||
}
|
||||
for (let i = 0; i < shift; i++) {
|
||||
const off = i * 2 * vec
|
||||
ag_snapL[keep + i] = ((sys.peek(ptr + off) & 0xFF) - 128) * inv128
|
||||
ag_snapR[keep + i] = ((sys.peek(ptr + off + vec) & 0xFF) - 128) * inv128
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wavelet analysis ───────────────────────────────────────────────────────
|
||||
//
|
||||
// In-place Haar decomposition. Five levels on 1024 samples gives band
|
||||
// passes (at 32 kHz): [8k..16k], [4k..8k], [2k..4k], [1k..2k], [500..1k].
|
||||
// Sub-500 Hz ends up in the approximation and is intentionally dropped —
|
||||
// otherwise the bass would dominate every track.
|
||||
|
||||
function ag_analyseHaar() {
|
||||
// mid = (L + R) / 2
|
||||
for (let i = 0; i < AG_SNAPSHOT_N; i++) {
|
||||
ag_workMid[i] = (ag_snapL[i] + ag_snapR[i]) * 0.5
|
||||
}
|
||||
let len = AG_SNAPSHOT_N
|
||||
const SQ_HALF = 0.70710678 // 1/sqrt(2) keeps L2 norm
|
||||
for (let lv = 0; lv < AG_N_BANDS; lv++) {
|
||||
const half = len >> 1
|
||||
let sumSq = 0
|
||||
for (let i = 0; i < half; i++) {
|
||||
const a = ag_workMid[i * 2]
|
||||
const b = ag_workMid[i * 2 + 1]
|
||||
const lo = (a + b) * SQ_HALF
|
||||
const hi = (a - b) * SQ_HALF
|
||||
ag_workMid[i] = lo
|
||||
ag_workTmp[i] = hi
|
||||
sumSq += hi * hi
|
||||
}
|
||||
// Higher-freq levels naturally have weaker energy in music; scale
|
||||
// each band by an empirical gain so all five read at comparable
|
||||
// brightness on typical material.
|
||||
const gain = 3.0 + lv * 1.5
|
||||
const rms = Math.sqrt(sumSq / half) * gain
|
||||
ag_bandEnergy[lv] = rms > 1 ? 1 : rms
|
||||
len = half
|
||||
}
|
||||
// Residual approximation in ag_workMid[0..len-1] holds the sub-500 Hz
|
||||
// energy that the modulator pipeline intentionally discards. Reuse it
|
||||
// to drive the bass mark.
|
||||
let bassSumSq = 0
|
||||
for (let i = 0; i < len; i++) {
|
||||
const v = ag_workMid[i]
|
||||
bassSumSq += v * v
|
||||
}
|
||||
const bassRms = Math.sqrt(bassSumSq / len) * 1.8
|
||||
ag_bassEnergy = bassRms > 1 ? 1 : bassRms
|
||||
}
|
||||
|
||||
// ── Mini-AAlib (embedded, for the wavescope) ───────────────────────────────
|
||||
//
|
||||
// Stripped port of `disk0/hopper/include/aa.mjs`, sized to one job: convert a
|
||||
// small pixel-space brightness buffer into ASCII glyphs with three monochrome
|
||||
// intensities (DIM / NORMAL / BOLD). No dither. No brightness / contrast /
|
||||
// gamma / inversion. No REVERSE / SPECIAL / BOLDFONT attribute support.
|
||||
// See aa.mjs for the full algorithm, credits (Jan Hubicka & the AA-group,
|
||||
// 1997), and the long-form comments — those are not duplicated here.
|
||||
//
|
||||
// Tables (params + 65536-entry LUT + filltable) are built once on first use
|
||||
// from the TSVM 7×14 font ROM, so the wavescope's glyph-selection matches the
|
||||
// brightness profile of the cells the hardware text mode actually paints.
|
||||
|
||||
const AA_FONT_PATH = "A:/tvdos/tsvm.chr"
|
||||
const AA_NORMAL = 0
|
||||
const AA_DIM = 1
|
||||
const AA_BOLD = 2
|
||||
const AA_NATTRS = 3
|
||||
const AA_NCHARS = 256 * AA_NATTRS
|
||||
const AA_DIMMUL = 5.3
|
||||
const AA_BOLDMUL = 2.7
|
||||
const AA_MUL = 8
|
||||
const AA_VAL = 13 // uniform-cell threshold
|
||||
const AA_PRIORITY = [4, 5, 3] // NORMAL, DIM, BOLD (matches aalib)
|
||||
|
||||
let aa_font = null // { width, height, data }
|
||||
let aa_params = null // Uint16Array((NCHARS+1)*5)
|
||||
let aa_table = null // Uint16Array(65536)
|
||||
let aa_filltable = null // Uint16Array(256)
|
||||
|
||||
function aa_loadFont() {
|
||||
if (aa_font) return aa_font
|
||||
const fh = files.open(AA_FONT_PATH)
|
||||
if (!fh.exists) throw Error("playgui: font ROM not found: " + AA_FONT_PATH)
|
||||
const blob = fh.bread()
|
||||
const FW = 7, FH = 14, ROM = 1920
|
||||
if (blob.length !== ROM && blob.length !== ROM * 2) {
|
||||
throw Error("playgui: bad font ROM size " + blob.length)
|
||||
}
|
||||
const data = new Uint8Array(256 * FW * FH)
|
||||
const halves = blob.length / ROM
|
||||
const startHalf = (halves === 2) ? 0 : 1
|
||||
for (let h = 0; h < halves; h++) {
|
||||
const romStart = h * ROM
|
||||
const charBase = (startHalf + h) * 128
|
||||
for (let c = 0; c < 128; c++) {
|
||||
const srcBase = romStart + c * FH
|
||||
const dstBase = (charBase + c) * FW * FH
|
||||
for (let r = 0; r < FH; r++) {
|
||||
const b = blob[srcBase + r] & 0xFF
|
||||
for (let x = 0; x < FW; x++) {
|
||||
data[dstBase + r * FW + x] = ((b >> (6 - x)) & 1) ? 0xFF : 0x00
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
aa_font = { width: FW, height: FH, data: data }
|
||||
return aa_font
|
||||
}
|
||||
|
||||
function aa_alowed(i) {
|
||||
const c = i & 0xff
|
||||
const attr = (i >>> 8)
|
||||
if (attr >= AA_NATTRS) return false
|
||||
// printable ASCII, space, or extended (>160) — keep AA_EIGHT chars so the
|
||||
// glyph palette includes the TSVM ROM's box-drawing / shade / dot range.
|
||||
if (!(c >= 33 && c <= 126) && c !== 0x20 && !(c > 160)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// (NE, NW, SE, SW) brightness for glyph `code` under `attr`. Quadrant labelling
|
||||
// follows aalib's bit-numbering quirk; the LUT lookup later swaps the halves
|
||||
// back to natural orientation. See aa.mjs:_glyphValues for the long-form note.
|
||||
function aa_glyphValues(code, attr, out) {
|
||||
const fd = aa_font.data
|
||||
const fw = aa_font.width
|
||||
const fh = aa_font.height
|
||||
const base = code * fw * fh
|
||||
const halfW = fw >> 1
|
||||
const halfH = fh >> 1
|
||||
const leftW = halfW
|
||||
const topH = halfH
|
||||
let v1 = 0, v2 = 0, v3 = 0, v4 = 0
|
||||
for (let r = 0; r < topH; r++) {
|
||||
const rowBase = base + r * fw
|
||||
for (let x = 0; x < leftW; x++) if (fd[rowBase + x]) v2++
|
||||
for (let x = leftW; x < fw; x++) if (fd[rowBase + x]) v1++
|
||||
}
|
||||
for (let r = topH; r < fh; r++) {
|
||||
const rowBase = base + r * fw
|
||||
for (let x = 0; x < leftW; x++) if (fd[rowBase + x]) v4++
|
||||
for (let x = leftW; x < fw; x++) if (fd[rowBase + x]) v3++
|
||||
}
|
||||
v1 *= AA_MUL; v2 *= AA_MUL; v3 *= AA_MUL; v4 *= AA_MUL
|
||||
if (attr === AA_DIM) {
|
||||
v1 = (v1 + 1) / AA_DIMMUL
|
||||
v2 = (v2 + 1) / AA_DIMMUL
|
||||
v3 = (v3 + 1) / AA_DIMMUL
|
||||
v4 = (v4 + 1) / AA_DIMMUL
|
||||
} else if (attr === AA_BOLD) {
|
||||
v1 *= AA_BOLDMUL
|
||||
v2 *= AA_BOLDMUL
|
||||
v3 *= AA_BOLDMUL
|
||||
v4 *= AA_BOLDMUL
|
||||
}
|
||||
out[0] = v1; out[1] = v2; out[2] = v3; out[3] = v4
|
||||
}
|
||||
|
||||
function aa_calcparams() {
|
||||
aa_loadFont()
|
||||
aa_params = new Uint16Array((AA_NCHARS + 1) * 5)
|
||||
const tmp = new Float64Array(4)
|
||||
let ma1 = 0, ma2 = 0, ma3 = 0, ma4 = 0, msum = 0
|
||||
let mi1 = 50000, mi2 = 50000, mi3 = 50000, mi4 = 50000, misum = 50000
|
||||
for (let i = 0; i < AA_NCHARS; i++) {
|
||||
if (!aa_alowed(i)) continue
|
||||
aa_glyphValues(i & 0xff, i >>> 8, tmp)
|
||||
const v1 = tmp[0], v2 = tmp[1], v3 = tmp[2], v4 = tmp[3]
|
||||
if (v1 > ma1) ma1 = v1
|
||||
if (v2 > ma2) ma2 = v2
|
||||
if (v3 > ma3) ma3 = v3
|
||||
if (v4 > ma4) ma4 = v4
|
||||
const s = v1 + v2 + v3 + v4
|
||||
if (s > msum) msum = s
|
||||
if (v1 < mi1) mi1 = v1
|
||||
if (v2 < mi2) mi2 = v2
|
||||
if (v3 < mi3) mi3 = v3
|
||||
if (v4 < mi4) mi4 = v4
|
||||
if (s < misum) misum = s
|
||||
}
|
||||
msum -= misum
|
||||
mi1 = misum / 4; mi2 = misum / 4; mi3 = misum / 4; mi4 = misum / 4
|
||||
ma1 = msum / 4; ma2 = msum / 4; ma3 = msum / 4; ma4 = msum / 4
|
||||
for (let i = 0; i < AA_NCHARS; i++) {
|
||||
aa_glyphValues(i & 0xff, i >>> 8, tmp)
|
||||
const v1r = tmp[0], v2r = tmp[1], v3r = tmp[2], v4r = tmp[3]
|
||||
const sr = v1r + v2r + v3r + v4r
|
||||
let sum = Math.floor((sr - misum) * (1020 / msum) + 0.5)
|
||||
let v1 = Math.floor((v1r - mi1) * (255 / ma1) + 0.5)
|
||||
let v2 = Math.floor((v2r - mi2) * (255 / ma2) + 0.5)
|
||||
let v3 = Math.floor((v3r - mi3) * (255 / ma3) + 0.5)
|
||||
let v4 = Math.floor((v4r - mi4) * (255 / ma4) + 0.5)
|
||||
if (v1 > 255) v1 = 255; else if (v1 < 0) v1 = 0
|
||||
if (v2 > 255) v2 = 255; else if (v2 < 0) v2 = 0
|
||||
if (v3 > 255) v3 = 255; else if (v3 < 0) v3 = 0
|
||||
if (v4 > 255) v4 = 255; else if (v4 < 0) v4 = 0
|
||||
if (sum > 1020) sum = 1020; else if (sum < 0) sum = 0
|
||||
aa_params[i * 5 + 0] = v1
|
||||
aa_params[i * 5 + 1] = v2
|
||||
aa_params[i * 5 + 2] = v3
|
||||
aa_params[i * 5 + 3] = v4
|
||||
aa_params[i * 5 + 4] = sum
|
||||
}
|
||||
}
|
||||
|
||||
function aa_pow2(x) { return x * x }
|
||||
function aa_pos(i1, i2, i3, i4) { return (i1 << 12) + (i2 << 8) + (i3 << 4) + i4 }
|
||||
function aa_dist(i1, i2, i3, i4, i5, y1, y2, y3, y4, y5) {
|
||||
return 2 * (aa_pow2(i1 - y1) + aa_pow2(i2 - y2) + aa_pow2(i3 - y3) + aa_pow2(i4 - y4))
|
||||
+ aa_pow2(i5 - y5)
|
||||
}
|
||||
function aa_dist1(i1, i2, i3, i4, i5, y1, y2, y3, y4, y5) {
|
||||
return aa_pow2(i1 - y1) + aa_pow2(i2 - y2) + aa_pow2(i3 - y3) + aa_pow2(i4 - y4)
|
||||
+ 2 * aa_pow2(i5 - y5)
|
||||
}
|
||||
|
||||
function aa_mktable() {
|
||||
if (!aa_params) aa_calcparams()
|
||||
aa_table = new Uint16Array(65536)
|
||||
aa_filltable = new Uint16Array(256)
|
||||
const next = new Int32Array(65536)
|
||||
for (let i = 0; i < 65536; i++) next[i] = i
|
||||
let first = -1, last = -1
|
||||
function add(i) {
|
||||
if (next[i] === i && last !== i) {
|
||||
if (last !== -1) { next[last] = i; last = i }
|
||||
else { last = first = i }
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < AA_NCHARS; i++) {
|
||||
if (!aa_alowed(i)) continue
|
||||
const i1 = aa_params[i * 5 + 0]
|
||||
const i2 = aa_params[i * 5 + 1]
|
||||
const i3 = aa_params[i * 5 + 2]
|
||||
const i4 = aa_params[i * 5 + 3]
|
||||
const i5 = aa_params[i * 5 + 4]
|
||||
const p1 = i1 >> 4, p2 = i2 >> 4, p3 = i3 >> 4, p4 = i4 >> 4
|
||||
const p = aa_pos(p1, p2, p3, p4)
|
||||
if (aa_table[p]) {
|
||||
const ex = aa_table[p]
|
||||
const ex1 = aa_params[ex * 5 + 0]
|
||||
const ex2 = aa_params[ex * 5 + 1]
|
||||
const ex3 = aa_params[ex * 5 + 2]
|
||||
const ex4 = aa_params[ex * 5 + 3]
|
||||
const ex5 = aa_params[ex * 5 + 4]
|
||||
const pp1 = (p1 << 4) | p1
|
||||
const pp2 = (p2 << 4) | p2
|
||||
const pp3 = (p3 << 4) | p3
|
||||
const pp4 = (p4 << 4) | p4
|
||||
const ppsum = pp1 + pp2 + pp3 + pp4
|
||||
const dNew = aa_dist(i1, i2, i3, i4, i5, pp1, pp2, pp3, pp4, ppsum)
|
||||
const dOld = aa_dist(ex1, ex2, ex3, ex4, ex5, pp1, pp2, pp3, pp4, ppsum)
|
||||
if (dNew > dOld) continue
|
||||
if (dNew === dOld && AA_PRIORITY[(i >>> 8)] <= AA_PRIORITY[(ex >>> 8)]) continue
|
||||
}
|
||||
aa_table[p] = i
|
||||
add(p)
|
||||
}
|
||||
for (let q = 0; q < 256; q++) {
|
||||
let mindist = Infinity
|
||||
let best = 0
|
||||
for (let i = 0; i < AA_NCHARS; i++) {
|
||||
if (!aa_alowed(i)) continue
|
||||
const d1 = aa_dist1(aa_params[i * 5 + 0], aa_params[i * 5 + 1],
|
||||
aa_params[i * 5 + 2], aa_params[i * 5 + 3],
|
||||
aa_params[i * 5 + 4],
|
||||
q, q, q, q, q * 4)
|
||||
if (d1 < mindist ||
|
||||
(d1 === mindist && AA_PRIORITY[(i >>> 8)] > AA_PRIORITY[(best >>> 8)])) {
|
||||
aa_filltable[q] = i
|
||||
mindist = d1
|
||||
best = i
|
||||
}
|
||||
}
|
||||
}
|
||||
// BFS propagation: claim neighbour slots that we cover better than whoever
|
||||
// got there first. Lifted verbatim from aamktabl.c via aa.mjs.
|
||||
while (true) {
|
||||
if (last !== -1) next[last] = last
|
||||
else break
|
||||
const blocked = last
|
||||
let i = first
|
||||
if (i === -1) break
|
||||
first = -1; last = -1
|
||||
let prev
|
||||
do {
|
||||
const m0 = (i >> 12) & 15
|
||||
const m1 = (i >> 8) & 15
|
||||
const m2 = (i >> 4) & 15
|
||||
const m3 = i & 15
|
||||
const c = aa_table[i]
|
||||
const cp0 = aa_params[c * 5 + 0]
|
||||
const cp1 = aa_params[c * 5 + 1]
|
||||
const cp2 = aa_params[c * 5 + 2]
|
||||
const cp3 = aa_params[c * 5 + 3]
|
||||
const cp4 = aa_params[c * 5 + 4]
|
||||
for (let dm = 0; dm < 4; dm++) {
|
||||
for (let sgn = -1; sgn <= 1; sgn += 2) {
|
||||
let n0 = m0, n1 = m1, n2 = m2, n3 = m3
|
||||
if (dm === 0) { n0 += sgn; if (n0 < 0 || n0 >= 16) continue }
|
||||
else if (dm === 1) { n1 += sgn; if (n1 < 0 || n1 >= 16) continue }
|
||||
else if (dm === 2) { n2 += sgn; if (n2 < 0 || n2 >= 16) continue }
|
||||
else { n3 += sgn; if (n3 < 0 || n3 >= 16) continue }
|
||||
const index = aa_pos(n0, n1, n2, n3)
|
||||
const ch = aa_table[index]
|
||||
if (ch === c || index === blocked) continue
|
||||
let replace = !ch
|
||||
if (!replace) {
|
||||
const ii1 = (n0 << 4) | n0
|
||||
const ii2 = (n1 << 4) | n1
|
||||
const ii3 = (n2 << 4) | n2
|
||||
const ii4 = (n3 << 4) | n3
|
||||
const iisum = ii1 + ii2 + ii3 + ii4
|
||||
const dNew = aa_dist(ii1, ii2, ii3, ii4, iisum,
|
||||
cp0, cp1, cp2, cp3, cp4)
|
||||
const dOld = aa_dist(ii1, ii2, ii3, ii4, iisum,
|
||||
aa_params[ch * 5 + 0],
|
||||
aa_params[ch * 5 + 1],
|
||||
aa_params[ch * 5 + 2],
|
||||
aa_params[ch * 5 + 3],
|
||||
aa_params[ch * 5 + 4])
|
||||
if (dNew < dOld) replace = true
|
||||
}
|
||||
if (replace) { aa_table[index] = c; add(index) }
|
||||
}
|
||||
}
|
||||
prev = i
|
||||
i = next[i]
|
||||
next[prev] = prev
|
||||
} while (i !== prev)
|
||||
}
|
||||
}
|
||||
|
||||
// Render an imgW × imgH brightness buffer (imgW = scrW*2, imgH = scrH*2) into
|
||||
// per-cell (glyph, attr) outputs. No dither, no params.
|
||||
function aa_render(img, scrW, scrH, tbOut, attrOut) {
|
||||
if (!aa_table) aa_mktable()
|
||||
const tbl = aa_table
|
||||
const fill = aa_filltable
|
||||
const wi = scrW * 2
|
||||
for (let y = 0; y < scrH; y++) {
|
||||
let pos = 2 * y * wi
|
||||
let pos1 = y * scrW
|
||||
for (let x = 0; x < scrW; x++) {
|
||||
const i1 = img[pos + 1] // NE
|
||||
const i2 = img[pos] // NW
|
||||
const i3 = img[pos + wi + 1] // SE
|
||||
const i4 = img[pos + wi] // SW
|
||||
const s = i1 + i2 + i3 + i4
|
||||
const avg = s >> 2
|
||||
let val
|
||||
if (Math.abs(i1 - avg) < AA_VAL &&
|
||||
Math.abs(i2 - avg) < AA_VAL &&
|
||||
Math.abs(i3 - avg) < AA_VAL &&
|
||||
Math.abs(i4 - avg) < AA_VAL) {
|
||||
val = fill[avg]
|
||||
} else {
|
||||
val = tbl[((i2 >> 4) << 12) | ((i1 >> 4) << 8) |
|
||||
((i4 >> 4) << 4) | (i3 >> 4)]
|
||||
}
|
||||
attrOut[pos1] = val >> 8
|
||||
tbOut[pos1] = val & 0xff
|
||||
pos += 2
|
||||
pos1 += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wavescope (rows 3..5) ──────────────────────────────────────────────────
|
||||
//
|
||||
// Peak-detected envelope plotted into a 156×6 pixel buffer (2× cell res),
|
||||
// then converted to ASCII glyphs by the mini-AAlib above. Mid-signal only —
|
||||
// stereo info lives on the bottom bar.
|
||||
//
|
||||
// Three monochrome intensities pick out the wave's body / peaks: DIM cells
|
||||
// are the dim trace, NORMAL cells are the bulk of the waveform, BOLD cells
|
||||
// land on the brightest patches (full-blocked peaks). Amber → white ramp
|
||||
// mimics phosphor bloom.
|
||||
|
||||
const AA_WAVE_W = AG_LANE_W // 78 cells
|
||||
const AA_WAVE_H = AG_ROW_WAVE_BOT - AG_ROW_WAVE_TOP + 1 // 3 cells
|
||||
const AA_WAVE_IW = AA_WAVE_W * 2 // 156 px
|
||||
const AA_WAVE_IH = AA_WAVE_H * 2 // 6 px
|
||||
|
||||
const ag_waveImg = new Uint8Array(AA_WAVE_IW * AA_WAVE_IH)
|
||||
const ag_waveTb = new Uint8Array(AA_WAVE_W * AA_WAVE_H)
|
||||
const ag_waveAttr = new Uint8Array(AA_WAVE_W * AA_WAVE_H)
|
||||
|
||||
// AA_NORMAL=0, AA_DIM=1, AA_BOLD=2 → amber phosphor palette.
|
||||
const AG_WAVE_FG = [166, 130, AG_COL_LABEL]
|
||||
|
||||
function ag_drawWavescope() {
|
||||
const N = AG_SNAPSHOT_N
|
||||
const IW = AA_WAVE_IW
|
||||
const IH = AA_WAVE_IH
|
||||
const img = ag_waveImg
|
||||
img.fill(0)
|
||||
|
||||
// Per-pixel-column envelope: vertical line from max to min sample value.
|
||||
const samplesPerCol = N / IW
|
||||
const yScale = (IH - 1) * 0.5
|
||||
for (let c = 0; c < IW; c++) {
|
||||
const s = (c * samplesPerCol) | 0
|
||||
const e = (((c + 1) * samplesPerCol) | 0)
|
||||
let mn = 1.0, mx = -1.0
|
||||
for (let i = s; i < e; i++) {
|
||||
const v = (ag_snapL[i] + ag_snapR[i]) * 0.5
|
||||
if (v < mn) mn = v
|
||||
if (v > mx) mx = v
|
||||
}
|
||||
// [-1, 1] → [0, IH-1]; +1 sits at the top, -1 at the bottom.
|
||||
let yT = ((1 - mx) * yScale + 0.5) | 0
|
||||
let yB = ((1 - mn) * yScale + 0.5) | 0
|
||||
if (yT < 0) yT = 0; else if (yT > IH - 1) yT = IH - 1
|
||||
if (yB < 0) yB = 0; else if (yB > IH - 1) yB = IH - 1
|
||||
for (let y = yT; y <= yB; y++) img[y * IW + c] = 0xFF
|
||||
}
|
||||
|
||||
aa_render(img, AA_WAVE_W, AA_WAVE_H, ag_waveTb, ag_waveAttr)
|
||||
|
||||
// Blit, skipping cells whose packed (attr<<8 | glyph) key is unchanged.
|
||||
for (let r = 0; r < AA_WAVE_H; r++) {
|
||||
for (let c = 0; c < AA_WAVE_W; c++) {
|
||||
const idx = r * AA_WAVE_W + c
|
||||
const att = ag_waveAttr[idx]
|
||||
const ch = ag_waveTb[idx]
|
||||
const key = (att << 8) | ch
|
||||
if (ag_waveGlyph[idx] === key) continue
|
||||
ag_waveGlyph[idx] = key
|
||||
ag_color(AG_WAVE_FG[att] || AG_COL_LABEL, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_WAVE_TOP + r, AG_COL_INSIDE_L + c, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── XY-scope persistence visualiser (rows 7..30) ───────────────────────────
|
||||
//
|
||||
// 45°-rotated vectorscope, standard convention. Each PCM sample plots at
|
||||
// col = centre_col + (L − R) · SX
|
||||
// row = centre_row + (L + R) · SY
|
||||
// giving the four canonical traces:
|
||||
// in-phase mono (L = R) → vertical line ((L−R)=0, (L+R) varies)
|
||||
// out-of-phase mono (L=−R) → horizontal line ((L+R)=0, (L−R) varies)
|
||||
// pure L (R = 0) → lower-right diagonal — the `\` axis
|
||||
// pure R (L = 0) → lower-left diagonal — the `/` axis
|
||||
// (Positive mono sits below centre because screen row increases downward.)
|
||||
// The glyph per cell follows channel dominance, the cell's intensity is
|
||||
// bumped on every hit, and a global decay shrinks stale traces back to zero.
|
||||
//
|
||||
// Wavelet energies are used as *modulators* — the design's central idea:
|
||||
//
|
||||
// transient → faster decay + scattered spark emission
|
||||
// bass/tonal → slower decay (sustained content breathes longer)
|
||||
// noise → small jitter on plot position (texture fuzz)
|
||||
//
|
||||
// TSVM terminal cells are ~2:1 (taller than wide); SX is set to ~2×SY so the
|
||||
// scope reads roughly circular under steady mono content.
|
||||
|
||||
const AG_XY_CX = AG_VIS_W >> 1 // centre column inside visualiser canvas
|
||||
const AG_XY_CY = AG_VIS_H >> 1 // centre row
|
||||
const AG_XY_SX = 18 // (L−R) → horizontal extent ±36 cells
|
||||
const AG_XY_SY = 9 // (L+R) → vertical extent ±18 cells
|
||||
|
||||
// Bass mark: 2×2 cell indicator pinned to the centre of the vectorscope so
|
||||
// the bass "subwoofer" sits underneath the beam's pivot point. Half-blocks
|
||||
// form a compact 16×16-pixel "dot" centred in the 16×32-pixel 2×2 area.
|
||||
const AG_BASS_VIS_R0 = AG_XY_CY - 1
|
||||
const AG_BASS_VIS_C0 = AG_XY_CX - 1
|
||||
const AG_BASS_VIS_R1 = AG_BASS_VIS_R0 + 1
|
||||
const AG_BASS_VIS_C1 = AG_BASS_VIS_C0 + 1
|
||||
const AG_BASS_SCR_R = AG_ROW_VIS_TOP + AG_BASS_VIS_R0
|
||||
const AG_BASS_SCR_C = AG_COL_INSIDE_L + AG_BASS_VIS_C0
|
||||
|
||||
// Glyphs.
|
||||
const AG_G_DOT = 0xFA // ·
|
||||
const AG_G_BSL = 0x5C // \\
|
||||
const AG_G_FSL = 0x2F // /
|
||||
const AG_G_XCR = 0x58 // X
|
||||
const AG_G_SPK = 0x2A // *
|
||||
const AG_G_HBAR = 0xC4 // ─
|
||||
|
||||
function ag_updateXYScope() {
|
||||
// Wavelet-driven modulators, all in [0, 1].
|
||||
const transient = ag_bandEnergy[AG_WL_TRANSIENT]
|
||||
const noise = ag_bandEnergy[AG_WL_NOISE]
|
||||
const sustain = ag_bandEnergy[AG_WL_BASS] * 0.6 + ag_bandEnergy[AG_WL_TONAL] * 0.4
|
||||
|
||||
// Decay: base 0.93, longer for sustained content, much shorter for sharp
|
||||
// transients. Clamped so a screaming hi-hat never freezes the trails and
|
||||
// a deep pad never overflows.
|
||||
let decay = 0.93 + 0.05 * (sustain > 1 ? 1 : sustain)
|
||||
- 0.10 * (transient > 1 ? 1 : transient)
|
||||
if (decay < 0.72) decay = 0.72
|
||||
if (decay > 0.985) decay = 0.985
|
||||
|
||||
// Decay all cells.
|
||||
for (let i = 0; i < ag_persist.length; i++) {
|
||||
ag_persist[i] *= decay
|
||||
}
|
||||
|
||||
// Plot every sample in the snapshot. Step 1 keeps lines continuous
|
||||
// visually; with 1024 samples per ~50 ms frame, most cells get multiple
|
||||
// hits and the persistence builds the "beam" silhouette.
|
||||
const SX = AG_XY_SX
|
||||
const SY = AG_XY_SY
|
||||
const cx = AG_XY_CX
|
||||
const cy = AG_XY_CY
|
||||
const jitterAmt = noise * 0.06 // noise-driven beam fuzz
|
||||
const plotBoost = 0.05
|
||||
|
||||
for (let i = 0; i < AG_SNAPSHOT_N; i++) {
|
||||
const L = ag_snapL[i]
|
||||
const R = ag_snapR[i]
|
||||
const mono = L + R // vertical axis ∈ [-2, 2]
|
||||
const side = L - R // horizontal axis ∈ [-2, 2]
|
||||
// Wavelet-driven jitter is symmetric — substitute a deterministic
|
||||
// pseudo-random by mixing the snapshot index so we don't churn the
|
||||
// shared Math.random() PRNG 1024× per frame.
|
||||
const jx = (((i * 1103515245 + 12345) & 0xFFFF) / 65536 - 0.5) * jitterAmt
|
||||
const jy = (((i * 1664525 + 1013904223) & 0xFFFF) / 65536 - 0.5) * jitterAmt
|
||||
let col = cx + ((side + jx) * SX) | 0
|
||||
let row = cy + ((mono + jy) * SY) | 0
|
||||
if (col < 0 || col >= AG_VIS_W || row < 0 || row >= AG_VIS_H) continue
|
||||
|
||||
const absL = L < 0 ? -L : L
|
||||
const absR = R < 0 ? -R : R
|
||||
let glyph
|
||||
if (absL + absR < 0.04) {
|
||||
glyph = AG_G_DOT
|
||||
} else if (absL > absR * 1.25) {
|
||||
glyph = AG_G_BSL // L-dominant → \
|
||||
} else if (absR > absL * 1.25) {
|
||||
glyph = AG_G_FSL // R-dominant → /
|
||||
} else {
|
||||
glyph = AG_G_XCR // mixed → X
|
||||
}
|
||||
|
||||
const idx = row * AG_VIS_W + col
|
||||
let nv = ag_persist[idx] + plotBoost
|
||||
if (nv > 1.0) nv = 1.0
|
||||
ag_persist[idx] = nv
|
||||
ag_persistGlyph[idx] = glyph
|
||||
}
|
||||
|
||||
// Transient spark emission — when high-freq energy peaks, scatter a few
|
||||
// bright `*` glyphs across the canvas. Cap at ~32 sparks to stay cheap.
|
||||
if (transient > 0.32) {
|
||||
const nSparks = ((transient - 0.32) * 60) | 0
|
||||
for (let s = 0; s < nSparks && s < 32; s++) {
|
||||
const c = (Math.random() * AG_VIS_W) | 0
|
||||
const r = (Math.random() * AG_VIS_H) | 0
|
||||
const idx = r * AG_VIS_W + c
|
||||
if (ag_persist[idx] < 0.85) ag_persist[idx] = 0.85
|
||||
ag_persistGlyph[idx] = AG_G_SPK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ag_drawVisualiser() {
|
||||
for (let r = 0; r < AG_VIS_H; r++) {
|
||||
const rowOff = r * AG_VIS_W
|
||||
const screenY = AG_ROW_VIS_TOP + r
|
||||
const inBassRow = (r === AG_BASS_VIS_R0 || r === AG_BASS_VIS_R1)
|
||||
for (let c = 0; c < AG_VIS_W; c++) {
|
||||
// Bass mark owns its 2×2 cells — let ag_drawBassMark() paint them.
|
||||
if (inBassRow && (c === AG_BASS_VIS_C0 || c === AG_BASS_VIS_C1)) continue
|
||||
const idx = rowOff + c
|
||||
const e = ag_persist[idx]
|
||||
let levelIdx = (e * 5) | 0
|
||||
if (levelIdx > 4) levelIdx = 4
|
||||
if (levelIdx < 0) levelIdx = 0
|
||||
const glyph = (levelIdx === 0) ? 0x20 : ag_persistGlyph[idx]
|
||||
const fg = AG_BEAM_PAL[levelIdx]
|
||||
if (ag_cellGlyph[idx] === glyph && ag_cellFg[idx] === fg) continue
|
||||
ag_cellGlyph[idx] = glyph
|
||||
ag_cellFg[idx] = fg
|
||||
ag_color(fg, AG_COL_BG)
|
||||
ag_mvprn(screenY, AG_COL_INSIDE_L + c, glyph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bass mark (rows 29-30, cols 2-3) ───────────────────────────────────────
|
||||
// Brightness-only indicator driven by the sub-500 Hz residual of the Haar
|
||||
// pyramid. Uses indices 1..4 of the beam palette so the dot never falls all
|
||||
// the way to background — a quiet track still shows a faint amber ember.
|
||||
|
||||
function ag_drawBassMark() {
|
||||
let idx = (ag_bassEnergy * 4) | 0
|
||||
if (idx > 3) idx = 3
|
||||
if (idx < 0) idx = 0
|
||||
const fg = AG_BEAM_PAL[idx + 1]
|
||||
if (fg === ag_lastBassFg) return
|
||||
ag_lastBassFg = fg
|
||||
ag_color(fg, AG_COL_BG)
|
||||
ag_mvprn(AG_BASS_SCR_R, AG_BASS_SCR_C, 0xDC)
|
||||
ag_mvprn(AG_BASS_SCR_R, AG_BASS_SCR_C + 1, 0xDC)
|
||||
ag_mvprn(AG_BASS_SCR_R + 1, AG_BASS_SCR_C, 0xDF)
|
||||
ag_mvprn(AG_BASS_SCR_R + 1, AG_BASS_SCR_C + 1, 0xDF)
|
||||
}
|
||||
|
||||
// ── Stereo energy bar (row 31) ─────────────────────────────────────────────
|
||||
//
|
||||
// Same idea as playtaud.drawStereo() but driven by raw PCM: for each sample,
|
||||
// pan = side/|mid| → bin index, energy = sqrt(|mid|+|side|). Gaussian-ish
|
||||
// 7-cell spread so individual sample clusters read as bars, not single spikes.
|
||||
|
||||
function ag_drawStereo() {
|
||||
const W = AG_LANE_W
|
||||
const bins = new Float32Array(W)
|
||||
const N = AG_SNAPSHOT_N
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const L = ag_snapL[i]
|
||||
const R = ag_snapR[i]
|
||||
const mid = (L + R) * 0.5
|
||||
const side = (L - R) * 0.5
|
||||
const absM = mid < 0 ? -mid : mid
|
||||
const absS = side < 0 ? -side : side
|
||||
// Pan estimate, clamped — `side/|mid|` blows up near silence so we
|
||||
// floor the denominator. This is a coarse stereo image, not a
|
||||
// calibrated readout.
|
||||
let pan = side / (absM + 0.02)
|
||||
if (pan < -1) pan = -1; else if (pan > 1) pan = 1
|
||||
const energy = Math.pow(absM + absS, 0.5)
|
||||
if (energy <= 0) continue
|
||||
|
||||
let col = ((pan + 1) * 0.5 * (W - 1)) | 0
|
||||
if (col < 0) col = 0; else if (col >= W) col = W - 1
|
||||
bins[col] += energy
|
||||
if (col >= 3) bins[col - 3] += energy * 0.05
|
||||
if (col >= 2) bins[col - 2] += energy * 0.3
|
||||
if (col >= 1) bins[col - 1] += energy * 0.75
|
||||
if (col < W - 1) bins[col + 1] += energy * 0.75
|
||||
if (col < W - 2) bins[col + 2] += energy * 0.3
|
||||
if (col < W - 3) bins[col + 3] += energy * 0.05
|
||||
}
|
||||
// Calibrated for "typical" 32 kHz × 1024-sample snapshot at modest level.
|
||||
const norm = 8.0 / N
|
||||
for (let i = 0; i < W; i++) {
|
||||
const v = bins[i] * norm
|
||||
let idx = (v * 1.6) | 0
|
||||
if (idx > 4) idx = 4
|
||||
if (idx < 0) idx = 0
|
||||
const glyph = AG_STAIRS[idx]
|
||||
const fg = AG_STEREO_COL[idx]
|
||||
if (ag_stereoGlyph[i] === glyph && ag_stereoFg[i] === fg) continue
|
||||
ag_stereoGlyph[i] = glyph
|
||||
ag_stereoFg[i] = fg
|
||||
ag_color(fg, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_STEREO, AG_COL_INSIDE_L + i, glyph)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// audioInit({ title, tag }): paint the static frame.
|
||||
// title : song title shown on row 2 (left)
|
||||
// tag : 3-5 char format label embedded in the top border (e.g. "WAV", "MP2")
|
||||
//
|
||||
// audioFeedPcm(ptr, sampleCount): hand the visualiser a fresh slice of
|
||||
// PCMu8-stereo-interleaved samples (typically the freshly decoded chunk).
|
||||
//
|
||||
// audioSetProgress(progress, elapsedSec, totalSec): update the title-row
|
||||
// progress bar. Cheap — only redraws on change.
|
||||
//
|
||||
// audioRender(): repaint wavescope + visualiser + stereo bar from the latest
|
||||
// snapshot. Internally rate-limited to ~20 Hz so callers can invoke
|
||||
// liberally without juggling frame timing.
|
||||
//
|
||||
// audioClose(): restore cursor + move out of the panel for a clean exit.
|
||||
|
||||
function audioInit(params) {
|
||||
ag_initParams = params || {}
|
||||
ag_lastRenderNs = 0
|
||||
ag_lastProgressIdx = -1
|
||||
ag_lastTimeStr = ''
|
||||
for (let i = 0; i < ag_snapL.length; i++) { ag_snapL[i] = 0; ag_snapR[i] = 0 }
|
||||
for (let i = 0; i < ag_persist.length; i++) ag_persist[i] = 0
|
||||
ag_persistGlyph.fill(0x20)
|
||||
ag_cellGlyph.fill(-1); ag_cellFg.fill(-1)
|
||||
ag_waveGlyph.fill(-1)
|
||||
ag_stereoGlyph.fill(-1); ag_stereoFg.fill(-1)
|
||||
ag_bassEnergy = 0
|
||||
ag_lastBassFg = -1
|
||||
|
||||
con.curs_set(0)
|
||||
con.clear()
|
||||
ag_drawFrame()
|
||||
ag_drawTitle()
|
||||
}
|
||||
|
||||
function audioSetProgress(progress, elapsedSec, totalSec) {
|
||||
if (progress < 0) progress = 0; else if (progress > 1) progress = 1
|
||||
ag_drawProgress(progress, elapsedSec | 0, totalSec | 0)
|
||||
}
|
||||
|
||||
function audioRender() {
|
||||
const now = sys.nanoTime()
|
||||
if (now - ag_lastRenderNs < AG_RENDER_INTERVAL_NS) return
|
||||
ag_lastRenderNs = now
|
||||
|
||||
ag_analyseHaar()
|
||||
ag_updateXYScope()
|
||||
ag_drawWavescope()
|
||||
ag_drawVisualiser()
|
||||
ag_drawBassMark()
|
||||
ag_drawStereo()
|
||||
}
|
||||
|
||||
function audioClose() {
|
||||
con.move(AG_ROW_BOT_BORDER + 1, 1)
|
||||
con.curs_set(1)
|
||||
}
|
||||
|
||||
// ── Exit polling ───────────────────────────────────────────────────────────
|
||||
// Mirror the Backspace-to-quit convention already in playtaud.
|
||||
|
||||
function audioIsExitRequested() {
|
||||
sys.poke(-40, 1)
|
||||
return sys.peek(-41) === 67
|
||||
}
|
||||
|
||||
exports = {
|
||||
clearSubtitleArea,
|
||||
displaySubtitle,
|
||||
printTopBar,
|
||||
printBottomBar
|
||||
printBottomBar,
|
||||
audioInit,
|
||||
audioFeedPcm,
|
||||
audioSetProgress,
|
||||
audioRender,
|
||||
audioClose,
|
||||
audioIsExitRequested
|
||||
}
|
||||
@@ -10,7 +10,15 @@ const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSV
|
||||
const TAUD_VERSION = 1
|
||||
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + sig(14)
|
||||
const TAUD_SONG_ENTRY = 32 // see encodeSongEntry / decodeSongEntry below
|
||||
const SAMPLEINST_SIZE = 786432 // 737280 sample + 49152 instrument (256 × 192)
|
||||
// Sample+instrument image: 8 MB sample pool (banked, 16 × 512 K) + 64 K instrument bin = 8256 kB total.
|
||||
// (terranmon.txt:1985-1997, 2533-2564 — bank-switched via MMIO 46.)
|
||||
const SAMPLE_BANK_SIZE = 524288 // 512 K — size of the sample-bin window
|
||||
const SAMPLE_BANK_COUNT = 16 // 16 banks × 512 K = 8 MB
|
||||
const SAMPLEBIN_SIZE = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT // 8 MB
|
||||
const INSTBIN_SIZE = 65536 // 256 inst × 256 bytes
|
||||
const SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE // 8454144 = 8256 kB
|
||||
const SAMPLEBIN_WINDOW_OFFSET = 0 // peripheral memory window for the active sample bank
|
||||
const INSTBIN_WINDOW_OFFSET = 720896 // peripheral memory offset of instrument bin
|
||||
const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
|
||||
const NUM_PATTERNS_MAX = 256
|
||||
const NUM_CUES = 1024
|
||||
@@ -75,11 +83,13 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
pos = 8
|
||||
|
||||
// -- 3. Parse header ------------------------------------------------------
|
||||
// version(1) + numSongs(1) + compressedSize(4) + rsvd(2) + signature(16) = 24 bytes
|
||||
// magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + signature(14)
|
||||
// = 32 bytes (terranmon.txt §Header).
|
||||
let version = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||
let numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||
let compressedSize = _peekU32LE(filePtr, pos); pos += 4
|
||||
pos += 18 // skip reserved(2) + signature(16)
|
||||
let projOff = _peekU32LE(filePtr, pos); pos += 4
|
||||
pos += 14 // signature
|
||||
// pos == 32 == TAUD_HEADER_SIZE
|
||||
|
||||
if (songIndex < 0 || songIndex >= numSongs) {
|
||||
@@ -88,18 +98,14 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
}
|
||||
|
||||
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
||||
let decompPtr = sys.malloc(SAMPLEINST_SIZE)
|
||||
gzip.decompFromTo(filePtr + pos, compressedSize, decompPtr)
|
||||
// The decompressed image is 8256 kB (8 MB samples bank-major + 64 K instruments)
|
||||
// which exceeds the 8 MB user-space cap, so we route through a hardware helper
|
||||
// that decompresses straight into the adapter's native sample/instrument
|
||||
// storage instead of staging a buffer in user memory.
|
||||
audio.uploadSampleInstBlob(filePtr + pos, compressedSize)
|
||||
audio.setSampleBank(0)
|
||||
pos += compressedSize
|
||||
|
||||
// Write decompressed data to peripheral memory (backwards addressing:
|
||||
// peripheral byte k lives at memBase - k).
|
||||
for (let i = 0; i < SAMPLEINST_SIZE; i++) {
|
||||
// TODO use sys.memcpy
|
||||
sys.poke(memBase - i, sys.peek(decompPtr + i))
|
||||
}
|
||||
sys.free(decompPtr)
|
||||
|
||||
// -- 5. Parse song-table entry for the requested song --------------------
|
||||
let entryOff = pos + songIndex * TAUD_SONG_ENTRY
|
||||
let songOffset = _peekU32LE(filePtr, entryOff)
|
||||
@@ -114,7 +120,7 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
let patBinCompSize = _peekU32LE(filePtr, entryOff + 18)
|
||||
let cueSheetCompSize = _peekU32LE(filePtr, entryOff + 22)
|
||||
|
||||
let bpm = bpmStored + 24
|
||||
let bpm = bpmStored + 25
|
||||
let patsToLoad = numPatsLo | (numPatsHi << 8)
|
||||
|
||||
// -- 6. Decompress + upload patterns --------------------------------------
|
||||
@@ -151,6 +157,50 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
||||
audio.setSongMixingVolume(playhead, songMixingVolume)
|
||||
|
||||
// -- 9. Project Data — walk Ixmp blocks for multi-sample instruments -----
|
||||
// Terranmon spec: Project Data starts at `projOff` (zero = absent), magic is
|
||||
// \x1ETaudPrJ + 8 reserved bytes, then a stream of FourCC + Uint32-length
|
||||
// sections. We only consume "Ixmp" here; other sections (PNam, INam, sMet,
|
||||
// etc.) are skipped so the player apps remain free to parse them.
|
||||
if (projOff !== 0 && projOff + 16 <= fileSize) {
|
||||
const projMagic = [0x1E,0x54,0x61,0x75,0x64,0x50,0x72,0x4A] // \x1ETaudPrJ
|
||||
let prjOk = true
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if ((sys.peek(filePtr + projOff + i) & 0xFF) !== projMagic[i]) { prjOk = false; break }
|
||||
}
|
||||
if (prjOk) {
|
||||
const PATCH_SIZE = 31
|
||||
let p = projOff + 16 // skip magic(8) + reserved(8)
|
||||
while (p + 8 <= fileSize) {
|
||||
const fc = String.fromCharCode(
|
||||
sys.peek(filePtr + p) & 0xFF, sys.peek(filePtr + p + 1) & 0xFF,
|
||||
sys.peek(filePtr + p + 2) & 0xFF, sys.peek(filePtr + p + 3) & 0xFF)
|
||||
const secLen = _peekU32LE(filePtr, p + 4)
|
||||
const payload = p + 8
|
||||
if (payload + secLen > fileSize) break
|
||||
if (fc === 'Ixmp') {
|
||||
// Each entry: Uint8 instId + Uint24 patchCount + (patchCount × PATCH_SIZE) bytes.
|
||||
let q = payload
|
||||
const qEnd = payload + secLen
|
||||
while (q + 4 <= qEnd) {
|
||||
const instId = sys.peek(filePtr + q) & 0xFF; q++
|
||||
const cntLo = sys.peek(filePtr + q) & 0xFF; q++
|
||||
const cntMid = sys.peek(filePtr + q) & 0xFF; q++
|
||||
const cntHi = sys.peek(filePtr + q) & 0xFF; q++
|
||||
const patchCnt = cntLo | (cntMid << 8) | (cntHi << 16)
|
||||
const blobLen = patchCnt * PATCH_SIZE
|
||||
if (q + blobLen > qEnd) break
|
||||
let buf = new Array(blobLen)
|
||||
for (let k = 0; k < blobLen; k++) buf[k] = sys.peek(filePtr + q + k) & 0xFF
|
||||
audio.uploadInstrumentPatches(instId, buf)
|
||||
q += blobLen
|
||||
}
|
||||
}
|
||||
p = payload + secLen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fileHandle.close()
|
||||
sys.free(filePtr)
|
||||
@@ -173,14 +223,19 @@ function captureTrackerDataToFile(outFile) {
|
||||
const baseAddr = audio.getBaseAddr()
|
||||
|
||||
// -- 1. Compress sample+instrument bin ------------------------------------
|
||||
// sys.memcpy(negative_src, positive_dst, len) copies peripheral byte k from
|
||||
// (memBase - k) into (sampleInstBuf + k).
|
||||
let sampleInstBuf = sys.malloc(SAMPLEINST_SIZE)
|
||||
sys.memcpy(memBase, sampleInstBuf, SAMPLEINST_SIZE)
|
||||
|
||||
let compBuf = sys.malloc(SAMPLEINST_SIZE + 4096) // headroom for incompressible data
|
||||
let compressedSize = gzip.compFromTo(sampleInstBuf, SAMPLEINST_SIZE, compBuf)
|
||||
sys.free(sampleInstBuf)
|
||||
// The 8256 kB raw image (8 MB samples + 64 K instruments) cannot fit in the
|
||||
// 8 MB user space, so we hand the entire compress step to a hardware helper
|
||||
// that reads directly out of the adapter's native sample/instrument storage.
|
||||
// Realistic sample data compresses well under both gzip and zstd; we cap the
|
||||
// destination at "uncompressed size + 8 K" headroom which suffices for any
|
||||
// sane musical content.
|
||||
const COMP_BUF_CAP = 1024 * 1024 * 4 // 4 MiB cap for compressed sample+inst blob
|
||||
let compBuf = sys.malloc(COMP_BUF_CAP)
|
||||
let compressedSize = audio.captureSampleInstBlob(compBuf, COMP_BUF_CAP)
|
||||
if (compressedSize > COMP_BUF_CAP) {
|
||||
sys.free(compBuf)
|
||||
throw Error("taud: compressed sample+inst blob exceeded " + COMP_BUF_CAP + " bytes (got " + compressedSize + ")")
|
||||
}
|
||||
|
||||
// -- 2. Find last non-empty pattern in bank 0 (all-zero = uninitialized) --
|
||||
let numPatsActual = 0
|
||||
@@ -201,7 +256,7 @@ function captureTrackerDataToFile(outFile) {
|
||||
// -- 3. BPM / tick-rate / volumes from playhead 0 -------------------------
|
||||
let bpm = audio.getBPM(0) || 125
|
||||
let tickRate = audio.getTickRate(0) || 6
|
||||
let bpmStored = (bpm - 24) & 0xFF
|
||||
let bpmStored = (bpm - 25) & 0xFF
|
||||
let songGlobalVolume = audio.getSongGlobalVolume(0)
|
||||
let songMixingVolume = audio.getSongMixingVolume(0)
|
||||
if (songGlobalVolume === undefined || songGlobalVolume === null) songGlobalVolume = 0x80
|
||||
@@ -263,7 +318,7 @@ function captureTrackerDataToFile(outFile) {
|
||||
(songOffset >>> 24) & 0xFF,
|
||||
20, // numVoices
|
||||
numPats & 0xFF, (numPats >>> 8) & 0xFF, // numPatterns Uint16 LE
|
||||
bpmStored, // BPM with −24 bias
|
||||
bpmStored, // BPM with −25 bias
|
||||
tickRate, // initial tick-rate
|
||||
0x00,0xA0, // basenote (0xA000 -- C9)
|
||||
0x00,0xAC,0x02,0x46, // basefreq (8363 Hz)
|
||||
|
||||
621
assets/disk0/tvdos/include/tbas.mjs
Normal file
621
assets/disk0/tvdos/include/tbas.mjs
Normal file
@@ -0,0 +1,621 @@
|
||||
// Terran BASIC runtime helper for compiled programs
|
||||
// Compiled-by: assets/disk0/tbas/compile.js
|
||||
// Loaded at runtime by `let bS = require("tbas")`
|
||||
//
|
||||
// Contract with compiler:
|
||||
// - The compiler has lowered every BASIC expression to a JS expression
|
||||
// that produces the *raw* JS value (number, string, array, ForGen,
|
||||
// function, BasicMemoMonad, …). Builtins take such raw values, NOT
|
||||
// SyntaxTreeReturnObj wrappers.
|
||||
// - Variable reads: bS.__state.vars.X (key always uppercased)
|
||||
// - Variable writes: bS.__state.vars.X = v
|
||||
// - Control flow (GOTO/GOSUB/RETURN/FOR/NEXT/IF/ON/END/READ/RESTORE/LABEL/DATA)
|
||||
// is *not* exposed here — the compiler emits inline JS that updates the
|
||||
// `pc` and `gosubStack` directly.
|
||||
//
|
||||
// Naming: BASIC builtins exposed under their UPPERCASE name (bS.PRINT,
|
||||
// bS.PLOT, bS.SIN). Compiler-only helpers prefixed with __.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isNumable(s) {
|
||||
if (Array.isArray(s)) return false
|
||||
if (s === undefined) return false
|
||||
if (typeof s.trim == "function" && s.trim().length == 0) return false
|
||||
return !isNaN(s)
|
||||
}
|
||||
const tonum = (t) => t * 1.0
|
||||
|
||||
function ForGen(s, e, t) {
|
||||
this.start = s
|
||||
this.end = e
|
||||
this.step = t || 1
|
||||
this.current = this.start
|
||||
this.stepsgn = (this.step > 0) ? 1 : -1
|
||||
}
|
||||
const isGenerator = (o) =>
|
||||
o !== undefined && o !== null &&
|
||||
o.start !== undefined && o.end !== undefined &&
|
||||
o.step !== undefined && o.stepsgn !== undefined
|
||||
const genToArray = (gen) => {
|
||||
let a = []
|
||||
let cur = gen.start
|
||||
while (cur * gen.stepsgn + gen.step * gen.stepsgn <= (gen.end + gen.step) * gen.stepsgn) {
|
||||
a.push(cur)
|
||||
cur += gen.step
|
||||
}
|
||||
return a
|
||||
}
|
||||
const genHasNext = (o) => o.current * o.stepsgn + o.step * o.stepsgn <= (o.end + o.step) * o.stepsgn
|
||||
const genGetNext = (gen, mutated) => {
|
||||
if (mutated !== undefined) gen.current = tonum(mutated)
|
||||
gen.current += gen.step
|
||||
return genHasNext(gen) ? gen.current : undefined
|
||||
}
|
||||
|
||||
function BasicMemoMonad(m) { this.mType = "value"; this.mVal = m }
|
||||
function BasicListMonad(m) { this.mType = "list"; this.mVal = [m] }
|
||||
function BasicFunSeq(f) { this.mType = "funseq"; this.mVal = f }
|
||||
const isMonad = (o) => o !== undefined && o !== null && o.mType !== undefined
|
||||
|
||||
function arrayToString(a) {
|
||||
let acc = ""
|
||||
for (let k = 0; k < a.length; k++) {
|
||||
if (k > 0) acc += ","
|
||||
acc += (Array.isArray(a[k])) ? arrayToString(a[k]) : a[k]
|
||||
}
|
||||
return "{" + acc + "}"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _initialConsts = () => ({
|
||||
NIL: [],
|
||||
PI: Math.PI,
|
||||
TAU: Math.PI * 2,
|
||||
EULER: Math.E,
|
||||
UNDEFINED: undefined,
|
||||
TRUE: true,
|
||||
FALSE: false,
|
||||
// ID is identity-function: emitted as JS arrow when needed
|
||||
ID: (x) => x,
|
||||
})
|
||||
|
||||
const state = {
|
||||
vars: _initialConsts(),
|
||||
indexBase: 0,
|
||||
dataConsts: [],
|
||||
dataCursor: 0,
|
||||
gotoLabels: {}, // labelName -> [lnum, stmt]
|
||||
lineList: [], // sorted ascending list of existing source lines (for GOTO snap)
|
||||
rnd: Math.random(),
|
||||
forVar: {}, // varname -> generator|array (the iterable we still owe to FOR/FOREACH)
|
||||
forLnums: {}, // varname -> [lnum, stmt of the FOR/FOREACH header]
|
||||
forStack: [],
|
||||
trace: false,
|
||||
debug: false,
|
||||
}
|
||||
|
||||
function __reset() {
|
||||
state.vars = _initialConsts()
|
||||
state.indexBase = 0
|
||||
state.dataConsts = []
|
||||
state.dataCursor = 0
|
||||
state.gotoLabels = {}
|
||||
state.lineList = []
|
||||
state.rnd = Math.random()
|
||||
state.forVar = {}
|
||||
state.forLnums = {}
|
||||
state.forStack = []
|
||||
}
|
||||
|
||||
function __data(values) { state.dataConsts = values.slice() }
|
||||
function __labels(map) { state.gotoLabels = Object.assign({}, map) }
|
||||
function __setLines(arr) { state.lineList = arr.slice() }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compiler-emitted operator helpers (need behaviour not directly expressible
|
||||
// in raw JS without losing semantics)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function __add(lh, rh) {
|
||||
return (!isNaN(lh) && !isNaN(rh)) ? (tonum(lh) + tonum(rh)) : (lh + rh)
|
||||
}
|
||||
function __div(lh, rh) { if (rh == 0) throw Error("Division by zero"); return lh / rh }
|
||||
function __intdiv(lh, rh) { if (rh == 0) throw Error("Division by zero"); return (lh / rh) | 0 }
|
||||
function __mod(lh, rh) { if (rh == 0) throw Error("Division by zero"); return lh % rh }
|
||||
function __pow(lh, rh) {
|
||||
let r = Math.pow(lh, rh)
|
||||
if (isNaN(r)) throw Error("Illegal function call")
|
||||
if (!isFinite(r)) throw Error("Division by zero")
|
||||
return r
|
||||
}
|
||||
|
||||
function __test(v) { return !!v } // matches builtin TEST: string "false" is truthy
|
||||
|
||||
function __dim(dims) {
|
||||
let revdims = dims.slice().reverse()
|
||||
let inner = new Array(revdims[0]).fill(0)
|
||||
for (let k = 1; k < revdims.length; k++) {
|
||||
const sz = revdims[k]
|
||||
const prev = inner
|
||||
inner = new Array(sz).fill(0).map(_ => JSON.parse(JSON.stringify(prev)))
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
function __subscriptError(idx, dim) {
|
||||
return Error("Subscript out of range (index " + idx + ", dim " + dim + ")")
|
||||
}
|
||||
function __arrGet(arr, idx) {
|
||||
let v = arr
|
||||
for (let i = 0; i < idx.length; i++) {
|
||||
if (v === undefined || v === null) throw __subscriptError(idx[i], i)
|
||||
v = v[idx[i] - state.indexBase]
|
||||
}
|
||||
return v
|
||||
}
|
||||
function __arrSet(arr, idx, value) {
|
||||
let v = arr
|
||||
for (let i = 0; i < idx.length - 1; i++) {
|
||||
if (v === undefined || v === null) throw __subscriptError(idx[i], i)
|
||||
v = v[idx[i] - state.indexBase]
|
||||
}
|
||||
if (v === undefined || v === null) throw __subscriptError(idx[idx.length - 1], idx.length - 1)
|
||||
v[idx[idx.length - 1] - state.indexBase] = value
|
||||
}
|
||||
|
||||
// FOR / FOREACH setup. Lowered as:
|
||||
// __forSetup(varname, iterable, bodyLnum, bodyStmt)
|
||||
// where iterable is a ForGen (FOR…TO…STEP) OR an Array (FOREACH IN…), and
|
||||
// (bodyLnum, bodyStmt) is the PC of the statement immediately following the
|
||||
// FOR header — i.e. where NEXT should jump back to. The compiler supplies
|
||||
// this directly so the state machine doesn't rely on fall-through.
|
||||
function __forSetup(varname, iterable, bodyLnum, bodyStmt) {
|
||||
const v = varname.toUpperCase()
|
||||
if (isGenerator(iterable)) {
|
||||
state.vars[v] = iterable.start
|
||||
state.forVar[v] = iterable
|
||||
} else if (Array.isArray(iterable)) {
|
||||
state.vars[v] = iterable[0]
|
||||
state.forVar[v] = iterable.slice(1) // remainder
|
||||
} else {
|
||||
throw Error("FOR: not a generator or array")
|
||||
}
|
||||
state.forLnums[v] = [bodyLnum, bodyStmt]
|
||||
state.forStack.push(v)
|
||||
}
|
||||
|
||||
// NEXT [varname]. Without varname, pops the most recent.
|
||||
// Returns [lnum, stmt] to jump back to (just-after the FOR header) if more
|
||||
// iterations remain, or undefined if the loop is exhausted (caller falls
|
||||
// through).
|
||||
function __forNext(varname) {
|
||||
let v
|
||||
if (varname === undefined || varname === null) {
|
||||
v = state.forStack.pop()
|
||||
} else {
|
||||
v = varname.toUpperCase()
|
||||
// remove this varname from the stack
|
||||
const idx = state.forStack.lastIndexOf(v)
|
||||
if (idx >= 0) state.forStack.splice(idx, 1)
|
||||
}
|
||||
if (v === undefined) throw Error("NEXT without FOR")
|
||||
|
||||
const it = state.forVar[v]
|
||||
let nextVal
|
||||
if (isGenerator(it)) {
|
||||
nextVal = genGetNext(it, state.vars[v])
|
||||
} else {
|
||||
nextVal = it.shift()
|
||||
}
|
||||
|
||||
if (nextVal !== undefined) {
|
||||
state.vars[v] = nextVal
|
||||
state.forStack.push(v)
|
||||
return state.forLnums[v] // already the PC of the loop body
|
||||
} else {
|
||||
if (isGenerator(it)) state.vars[v] = it.current
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function __readData() {
|
||||
const r = state.dataConsts[state.dataCursor++]
|
||||
if (r === undefined) throw Error("Out of DATA")
|
||||
return r
|
||||
}
|
||||
|
||||
// Resolve a GOTO/GOSUB target — accepts numeric line, label string, or
|
||||
// already-evaluated expression. For numeric targets that don't match an
|
||||
// existing source line, snap upward to the next one (matches the
|
||||
// interpreter's behaviour, where the main loop simply increments lnum until
|
||||
// it finds a populated cmdbuf entry).
|
||||
function __resolveTarget(t) {
|
||||
if (typeof t === "string" && state.gotoLabels[t] !== undefined) {
|
||||
return state.gotoLabels[t]
|
||||
}
|
||||
let target
|
||||
if (typeof t === "number") target = t
|
||||
else if (isNumable(t)) target = tonum(t)
|
||||
else throw Error("Invalid jump target: " + t)
|
||||
|
||||
const lines = state.lineList
|
||||
if (lines.length === 0) return [target, 0]
|
||||
// linear scan is fine for the line counts BASIC programs reach
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i] >= target) return [lines[i], 0]
|
||||
}
|
||||
return [Infinity, 0]
|
||||
}
|
||||
|
||||
// Invoke a usrdefun (compiled to a JS function), or — when the parser
|
||||
// couldn't tell array-indexing apart from function-call (e.g. `A(5)` for an
|
||||
// unknown identifier) — index into an array. Used by MAP/FOLD/FILTER, monad
|
||||
// operators, and the compiler's default `function` lowering.
|
||||
function __runFn(fn, args) {
|
||||
if (typeof fn === "function") return fn.apply(null, args)
|
||||
if (Array.isArray(fn)) return __arrGet(fn, args)
|
||||
if (isMonad(fn) && fn.mType === "funseq") {
|
||||
let arg = args[0]
|
||||
for (let i = 0; i < fn.mVal.length; i++) arg = __runFn(fn.mVal[i], [arg])
|
||||
return arg
|
||||
}
|
||||
throw Error("Not a callable: " + fn)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operator builtins (where JS doesn't already do the right thing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _AND(a, b) { if (typeof a !== "boolean" || typeof b !== "boolean") throw Error("Type mismatch"); return a && b }
|
||||
function _OR (a, b) { if (typeof a !== "boolean" || typeof b !== "boolean") throw Error("Type mismatch"); return a || b }
|
||||
function _NOT(a) { return !a }
|
||||
|
||||
function _CONS(lh, rh) { // !
|
||||
if (Array.isArray(rh)) return [lh].concat(rh)
|
||||
if (rh && rh.mType === "list") { rh.mVal = [lh].concat(rh.mVal); return rh }
|
||||
throw Error("Type mismatch")
|
||||
}
|
||||
function _PUSH(lh, rh) { // ~
|
||||
if (Array.isArray(lh)) return lh.concat([rh])
|
||||
if (lh && lh.mType === "list") { lh.mVal = [lh.mVal].concat([rh]); return lh }
|
||||
throw Error("Type mismatch")
|
||||
}
|
||||
function _CONCAT(lh, rh) { // #
|
||||
if (Array.isArray(lh) && Array.isArray(rh)) return lh.concat(rh)
|
||||
if (lh && rh && lh.mType === "list" && rh.mType === "list") return new BasicListMonad(lh.mVal.concat(rh.mVal))
|
||||
throw Error("Type mismatch")
|
||||
}
|
||||
|
||||
function _TO(from, to) { return new ForGen(from, to, 1) }
|
||||
function _STEP(gen, step) {
|
||||
if (!isGenerator(gen)) throw Error("Type mismatch (STEP)")
|
||||
return new ForGen(gen.start, gen.end, step)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I/O builtins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PRINT(values, seps) — values: array of resolved JS values; seps: array of
|
||||
// length values.length-1 with "," | ";" between each consecutive pair.
|
||||
// Trailing semicolon? The compiler signals "no newline" by passing a final
|
||||
// `null` element in `values` and "noNewline" flag — we use the convention
|
||||
// that the LAST entry of `values` being a marker `__noNewline` suppresses
|
||||
// the newline (matches basic.js trailing-null behaviour).
|
||||
const __PRINT_NONL = Symbol("PRINT_NONL")
|
||||
function PRINT(values, seps) {
|
||||
seps = seps || []
|
||||
if (values.length === 0) {
|
||||
println()
|
||||
return
|
||||
}
|
||||
let suppressNewline = false
|
||||
let realLen = values.length
|
||||
if (values[realLen - 1] === __PRINT_NONL) {
|
||||
suppressNewline = true
|
||||
realLen -= 1
|
||||
}
|
||||
for (let i = 0; i < realLen; i++) {
|
||||
if (i >= 1 && seps[i - 1] === ",") print("\t")
|
||||
const v = values[i]
|
||||
let s
|
||||
if (Array.isArray(v)) s = arrayToString(v)
|
||||
else if (v === undefined || v === "") s = ""
|
||||
else if (v.toString !== undefined) s = v.toString()
|
||||
else s = v
|
||||
print(s)
|
||||
}
|
||||
if (!suppressNewline) println()
|
||||
}
|
||||
function EMIT(values, seps) {
|
||||
seps = seps || []
|
||||
if (values.length === 0) { println(); return }
|
||||
let suppressNewline = false
|
||||
let realLen = values.length
|
||||
if (values[realLen - 1] === __PRINT_NONL) { suppressNewline = true; realLen -= 1 }
|
||||
for (let i = 0; i < realLen; i++) {
|
||||
if (i >= 1 && seps[i - 1] === ",") print("\t")
|
||||
const v = values[i]
|
||||
if (v === undefined) print("")
|
||||
else if (isNumable(v)) {
|
||||
const c = con.getyx()
|
||||
con.addch(tonum(v))
|
||||
con.move(c[0], c[1] + 1)
|
||||
} else if (v.toString !== undefined) print(v.toString())
|
||||
else print(v)
|
||||
}
|
||||
if (!suppressNewline) println()
|
||||
}
|
||||
|
||||
function INPUT(promptOrVarname) {
|
||||
print("? ")
|
||||
let r = sys.read().trim()
|
||||
if (!isNaN(r)) r = tonum(r)
|
||||
return r
|
||||
}
|
||||
function CIN() { return sys.read().trim() }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Numeric builtins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _num = (f) => (x) => { if (!isNumable(x)) throw Error("Type mismatch"); return f(tonum(x)) }
|
||||
const _num2 = (f) => (a, b) => {
|
||||
if (!isNumable(a) || !isNumable(b)) throw Error("Type mismatch")
|
||||
return f(tonum(a), tonum(b))
|
||||
}
|
||||
|
||||
const ABS = _num(Math.abs)
|
||||
const SGN = _num(x => x > 0 ? 1 : x < 0 ? -1 : 0)
|
||||
const INT = _num(Math.floor)
|
||||
const FLOOR = _num(Math.floor)
|
||||
const CEIL = _num(Math.ceil)
|
||||
const FIX = _num(x => x | 0)
|
||||
const ROUND = _num(Math.round)
|
||||
const SQR = _num(Math.sqrt)
|
||||
const CBR = _num(Math.cbrt)
|
||||
const SIN = _num(Math.sin)
|
||||
const COS = _num(Math.cos)
|
||||
const TAN = _num(Math.tan)
|
||||
const ASN = _num(Math.asin)
|
||||
const ACO = _num(Math.acos)
|
||||
const ATN = _num(Math.atan)
|
||||
const SINH = _num(Math.sinh)
|
||||
const COSH = _num(Math.cosh)
|
||||
const TANH = _num(Math.tanh)
|
||||
const EXP = _num(Math.exp)
|
||||
const LOG = _num(Math.log)
|
||||
const MIN = _num2((a,b) => a > b ? b : a)
|
||||
const MAX = _num2((a,b) => a < b ? b : a)
|
||||
|
||||
function RND(x) {
|
||||
// matches basic.js:1199 — only re-roll when arg !== 0
|
||||
if (!(x === 0)) state.rnd = Math.random()
|
||||
return state.rnd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String builtins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SPC(n) { return " ".repeat(n) }
|
||||
function LEFT(s, n) { return String(s).substring(0, n) }
|
||||
function RIGHT(s, n) { return String(s).substring(String(s).length - n) }
|
||||
function MID(s, start, len) { return String(s).substring(start - state.indexBase, start - state.indexBase + len) }
|
||||
function CHR(n) { return String.fromCharCode(n) }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List builtins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LEN(x) { if (x === undefined || x.length === undefined) throw Error("Type mismatch"); return x.length }
|
||||
function HEAD(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x[0] }
|
||||
function TAIL(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x.slice(1) }
|
||||
function INIT(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x.slice(0, x.length - 1) }
|
||||
function LAST(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x[x.length - 1] }
|
||||
|
||||
function MAP(fn, functor) {
|
||||
if (typeof fn !== "function" && !(isMonad(fn) && fn.mType === "funseq")) throw Error("MAP: not a function")
|
||||
if (isGenerator(functor)) functor = genToArray(functor)
|
||||
if (!Array.isArray(functor)) throw Error("MAP: not iterable")
|
||||
return functor.map(it => __runFn(fn, [it]))
|
||||
}
|
||||
function FOLD(fn, init, functor) {
|
||||
if (typeof fn !== "function" && !(isMonad(fn) && fn.mType === "funseq")) throw Error("FOLD: not a function")
|
||||
if (isGenerator(functor)) functor = genToArray(functor)
|
||||
if (!Array.isArray(functor)) throw Error("FOLD: not iterable")
|
||||
let akku = init
|
||||
for (let i = 0; i < functor.length; i++) akku = __runFn(fn, [akku, functor[i]])
|
||||
return akku
|
||||
}
|
||||
function FILTER(fn, functor) {
|
||||
if (typeof fn !== "function" && !(isMonad(fn) && fn.mType === "funseq")) throw Error("FILTER: not a function")
|
||||
if (isGenerator(functor)) functor = genToArray(functor)
|
||||
if (!Array.isArray(functor)) throw Error("FILTER: not iterable")
|
||||
return functor.filter(it => __runFn(fn, [it]))
|
||||
}
|
||||
|
||||
// Array literal constructor — emitted by the compiler for `[a,b,c]` syntax
|
||||
function ARRAY() { return Array.prototype.slice.call(arguments) }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graphics / system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CLS() { con.clear() }
|
||||
function CLPX() { graphics.clearPixels(255) }
|
||||
function PLOT(x, y, c) { graphics.plotPixel(x, y, c) }
|
||||
function GOTOYX(y, x) { con.move(y + (1 - state.indexBase), x + (1 - state.indexBase)) }
|
||||
function TEXTFORE(c) { print(String.fromCharCode(27, 91) + "38;5;" + (c | 0) + "m") }
|
||||
function TEXTBACK(c) { print(String.fromCharCode(27, 91) + "48;5;" + (c | 0) + "m") }
|
||||
function POKE(addr, v) { sys.poke(addr, v) }
|
||||
function PEEK(addr) { return sys.peek(addr) }
|
||||
function GETKEYSDOWN() {
|
||||
const keys = []
|
||||
sys.poke(-40, 255)
|
||||
for (let k = -41; k >= -48; k--) keys.push(sys.peek(k))
|
||||
return keys
|
||||
}
|
||||
|
||||
function CPUT(devnum, msg) { com.sendMessage(devnum, msg); return com.getStatusCode(devnum) }
|
||||
function CGET(devnum, ptr) {
|
||||
const msg = com.pullMessage(devnum)
|
||||
const len = msg.length | 0
|
||||
for (let i = 0; i < len; i++) sys.poke(ptr + i, msg.charCodeAt(i))
|
||||
return len
|
||||
}
|
||||
function CSTA(devnum) { return com.getStatusCode(devnum) }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type / debug
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TYPEOF(v) {
|
||||
if (v === undefined) return "null"
|
||||
if (typeof v === "boolean") return "bool"
|
||||
if (Array.isArray(v)) return "array"
|
||||
if (isGenerator(v)) return "generator"
|
||||
if (isMonad(v)) return v.mType + "-monad"
|
||||
if (typeof v === "function") return "usrdefun"
|
||||
if (isNumable(v)) return "num"
|
||||
if (typeof v === "string") return "string"
|
||||
return typeof v
|
||||
}
|
||||
|
||||
function OPTIONBASE(n) {
|
||||
if (n != 0 && n != 1) throw Error("Syntax error: OPTIONBASE")
|
||||
state.indexBase = n | 0
|
||||
}
|
||||
function OPTIONDEBUG(n) { state.debug = (n | 0) === 1 }
|
||||
function OPTIONTRACE(n) { state.trace = (n | 0) === 1 }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monad / functional ops (best-effort port)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MRET(v) { return new BasicMemoMonad(v) }
|
||||
function MLIST(v) { return new BasicListMonad(v) }
|
||||
function MJOIN(m) { if (!isMonad(m)) throw Error("Type mismatch"); return m.mVal }
|
||||
|
||||
function _BIND(ma, fn) { // >>=
|
||||
if (!isMonad(ma)) throw Error(">>=: left is not a monad")
|
||||
if (typeof fn !== "function") throw Error(">>=: right is not a function")
|
||||
const mb = __runFn(fn, [ma.mVal])
|
||||
if (!isMonad(mb)) throw Error(">>=: function did not return a monad")
|
||||
return mb
|
||||
}
|
||||
function _SEQ(ma, mb) { // >>~
|
||||
if (!isMonad(ma) || !isMonad(mb)) throw Error("Type mismatch")
|
||||
return mb
|
||||
}
|
||||
function _COMPOSE(fa, fb) { // .
|
||||
const ma = (typeof fa === "function") ? [fa] : fa.mVal
|
||||
const mb = (typeof fb === "function") ? [fb] : fb.mVal
|
||||
return new BasicFunSeq(mb.concat(ma))
|
||||
}
|
||||
function _APPLY(fn, value) { // $
|
||||
return __runFn(fn, [value])
|
||||
}
|
||||
function _PIPE(value, fn) { // &
|
||||
return _APPLY(fn, value)
|
||||
}
|
||||
function _CURRY(fn, value) { // ~<
|
||||
if (typeof fn !== "function") throw Error("~<: left is not a function")
|
||||
return function() {
|
||||
const rest = Array.prototype.slice.call(arguments)
|
||||
return fn.apply(null, [value].concat(rest))
|
||||
}
|
||||
}
|
||||
function _SEQAPP(fns, functor) { // <*>
|
||||
if (!Array.isArray(fns)) throw Error("<*>: first arg must be an array of functions")
|
||||
if (isGenerator(functor)) functor = genToArray(functor)
|
||||
if (!Array.isArray(functor)) throw Error("<*>: not iterable")
|
||||
let ret = []
|
||||
for (let i = 0; i < fns.length; i++) ret = ret.concat(functor.map(it => __runFn(fns[i], [it])))
|
||||
return ret
|
||||
}
|
||||
function _SEQCURRYMAP(fns, functor) { // <~>
|
||||
if (typeof fns === "function") fns = [fns]
|
||||
if (!Array.isArray(fns)) throw Error("<~>: first arg must be a function or array of functions")
|
||||
if (isGenerator(functor)) functor = genToArray(functor)
|
||||
if (!Array.isArray(functor)) throw Error("<~>: not iterable")
|
||||
let ret = []
|
||||
for (let i = 0; i < fns.length; i++) ret = ret.concat(functor.map(it => _CURRY(fns[i], it)))
|
||||
return ret
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
exports = {
|
||||
// state & introspection
|
||||
__state: state, __reset, __data, __labels, __setLines,
|
||||
__PRINT_NONL,
|
||||
|
||||
// operator helpers
|
||||
__add, __div, __intdiv, __mod, __pow, __test,
|
||||
__dim, __arrGet, __arrSet,
|
||||
__forSetup, __forNext, __readData, __resolveTarget,
|
||||
__runFn,
|
||||
|
||||
// type ctors
|
||||
__ForGen: ForGen, __isGenerator: isGenerator, __genToArray: genToArray,
|
||||
__isMonad: isMonad,
|
||||
|
||||
// operators
|
||||
AND: _AND, OR: _OR, NOT: _NOT,
|
||||
UNARYLOGICNOT: _NOT,
|
||||
UNARYBNOT: (a) => ~a,
|
||||
UNARYMINUS: (a) => -a,
|
||||
UNARYPLUS: (a) => +a,
|
||||
BAND: (a,b)=>a&b, BOR: (a,b)=>a|b, BXOR: (a,b)=>a^b,
|
||||
"<<": (a,b)=>a<<b, ">>": (a,b)=>a>>>b,
|
||||
"!": _CONS, "~": _PUSH, "#": _CONCAT,
|
||||
TO: _TO, STEP: _STEP,
|
||||
|
||||
// i/o
|
||||
PRINT, EMIT, INPUT, CIN,
|
||||
|
||||
// numeric
|
||||
ABS, SGN, INT, FLOOR, CEIL, FIX, ROUND, SQR, CBR,
|
||||
SIN, COS, TAN, ASN, ACO, ATN, SINH, COSH, TANH,
|
||||
EXP, LOG, MIN, MAX, RND,
|
||||
|
||||
// strings
|
||||
SPC, LEFT, RIGHT, MID, CHR,
|
||||
|
||||
// lists
|
||||
LEN, HEAD, TAIL, INIT, LAST, MAP, FOLD, FILTER,
|
||||
ARRAY,
|
||||
|
||||
// graphics / system
|
||||
CLS, CLPX, PLOT, GOTOYX, TEXTFORE, TEXTBACK,
|
||||
POKE, PEEK, GETKEYSDOWN, CPUT, CGET, CSTA,
|
||||
|
||||
// type / option
|
||||
TYPEOF, OPTIONBASE, OPTIONDEBUG, OPTIONTRACE,
|
||||
|
||||
// monads / functional
|
||||
MRET, MLIST, MJOIN,
|
||||
">>=": _BIND, ">>~": _SEQ,
|
||||
".": _COMPOSE, "$": _APPLY, "&": _PIPE, "~<": _CURRY,
|
||||
"<*>": _SEQAPP, "<$>": MAP, "<~>": _SEQCURRYMAP,
|
||||
|
||||
// misc
|
||||
DO: function() { return arguments[arguments.length - 1] },
|
||||
CLEAR: function() { state.vars = _initialConsts() },
|
||||
END: function() { /* compiler emits pc=[Infinity,0] */ },
|
||||
LABEL: function() { /* harvested at compile time */ },
|
||||
DATA: function() { /* harvested at compile time */ },
|
||||
// DIM as an expression (e.g. `WS = DIM(H, V)`): allocate and return a
|
||||
// freshly zero-filled N-D array. The statement form `DIM A(H, V)` is
|
||||
// compiled inline and never reaches this entry.
|
||||
DIM: function() { return __dim(Array.prototype.slice.call(arguments)) },
|
||||
}
|
||||
331
assets/disk0/tvdos/include/typesetter.mjs
Normal file
331
assets/disk0/tvdos/include/typesetter.mjs
Normal file
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* typesetter.mjs - Rich-text typesetter for TVDOS console output.
|
||||
*
|
||||
* Wraps and aligns text using a tiny markup language. Originally lifted
|
||||
* out of taut_helpmsg.js so other tools (motd, help popups, ...) can
|
||||
* share the same formatter.
|
||||
*
|
||||
* Markup
|
||||
* ------
|
||||
* <b>...</b> emphasised foreground colour
|
||||
* <c>...</c> centre-align this source line
|
||||
* <r>...</r> right-align this source line
|
||||
* <l>...</l> left-align this source line
|
||||
* <o>...</o> virtual typesetting box. Left anchor is the cursor
|
||||
* column at the open tag, right anchor is the wrap edge.
|
||||
* default alignment is fully justified (override per-call via opts).
|
||||
*
|
||||
* Entities
|
||||
* --------
|
||||
* µtone; "Microtone" wordmark
|
||||
* &bul; &ddot; &mdot; bullet glyphs
|
||||
* &updn; &udlr; arrow glyphs
|
||||
* &keyoffsym; ¬ecutsym;
|
||||
* &demisharp; ♯ &sesquisharp; &doublesharp; &triplesharp; &quadsharp;
|
||||
* &demiflat; ♭ &sesquiflat; &doubleflat; &tripleflat; &quadflat;
|
||||
* &accuptick; &accdntick; &accupup; &accdndn;
|
||||
* non-breaking space
|
||||
* ­ soft hyphen (currently dropped)
|
||||
* < > literal angle brackets
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* let ts = require("typesetter")
|
||||
* let lines = ts.typeset(text, width) // array of width-wide strings
|
||||
* let lines = ts.typeset(text) // width = rest of current row
|
||||
* let lines = ts.typeset(text, width, { defaultAlign: 'l' })
|
||||
*/
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Palette / ANSI helpers
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const COL_TEXT = 239 // popup body default (== colWHITE)
|
||||
const COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
||||
const COL_BRAND = 211 // first half of "Microtone"
|
||||
const COL_BRAND_DIM = 239 // second half of "Microtone"
|
||||
|
||||
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
||||
const ESC_DEFAULT = fgEsc(COL_TEXT)
|
||||
const ESC_EMPH = fgEsc(COL_EMPH)
|
||||
const MICROTONE = `${fgEsc(COL_BRAND)}Micro${fgEsc(COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Entity expansion
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Replace &xxx; entities with their final printable representations.
|
||||
function expandEntities(s) {
|
||||
return s
|
||||
.replaceAll('µtone;', MICROTONE)
|
||||
.replaceAll('&bul;', '\u00F9')
|
||||
.replaceAll('&ddot;', '\u008419u')
|
||||
.replaceAll('&mdot;', '\u00FA')
|
||||
.replaceAll('&updn;', '\u008418u')
|
||||
.replaceAll('&udlr;', '\u008428u\u008429u')
|
||||
.replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1')
|
||||
.replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
|
||||
.replaceAll(' ', '\u007F')
|
||||
.replaceAll('­', '')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&demisharp;', '\u0080\u0081')
|
||||
.replaceAll('♯', '\u0082\u0083')
|
||||
.replaceAll('&sesquisharp;', '\u0084132u\u0085')
|
||||
.replaceAll('&doublesharp;', '\u0086\u0087')
|
||||
.replaceAll('&triplesharp;', '\u0088\u0089')
|
||||
.replaceAll('&quadsharp;', '\u008A\u008B')
|
||||
.replaceAll('&demiflat;', '\u008C\u008D')
|
||||
.replaceAll('♭', '\u008E\u008F')
|
||||
.replaceAll('&sesquiflat;', '\u0090\u0091')
|
||||
.replaceAll('&doubleflat;', '\u0092\u0093')
|
||||
.replaceAll('&tripleflat;', '\u0094\u0095')
|
||||
.replaceAll('&quadflat;', '\u0096\u0097')
|
||||
.replaceAll('&accuptick;', '\u009A')
|
||||
.replaceAll('&accdntick;', '\u009B')
|
||||
.replaceAll('&accupup;', '\u009C')
|
||||
.replaceAll('&accdndn;', '\u009D')
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tokeniser
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Tokenise a (post-entity-expansion) line. Returns an array of:
|
||||
// {type:'word', text:String, w:int} - non-breakable run of visible chars (may carry ANSI escapes)
|
||||
// {type:'sp'} - a single soft space (eligible for break/expansion)
|
||||
// {type:'anchor', open:Boolean} - <o>/</o> markers (zero width)
|
||||
//
|
||||
// Width accounting:
|
||||
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
|
||||
// - TSVM unicode escapes (`\u0084..u`) : 1 visible char
|
||||
// - non-breaking space (\u007F) : 1 visible char (consumed as part of a word)
|
||||
// - soft hyphen (\u00AD) : dropped (not implemented as a break point)
|
||||
// - everything else : 1 visible char
|
||||
function tokenise(line) {
|
||||
const tokens = []
|
||||
let buf = ''
|
||||
let bufW = 0
|
||||
let i = 0
|
||||
|
||||
const flushWord = () => {
|
||||
if (buf.length > 0) {
|
||||
tokens.push({type: 'word', text: buf, w: bufW})
|
||||
buf = ''
|
||||
bufW = 0
|
||||
}
|
||||
}
|
||||
|
||||
while (i < line.length) {
|
||||
// inline tags (case-sensitive for <b>, case-insensitive for <o>)
|
||||
if (line.slice(i, i + 3) === '<b>') { buf += ESC_EMPH; i += 3; continue }
|
||||
if (line.slice(i, i + 4) === '</b>') { buf += ESC_DEFAULT; i += 4; continue }
|
||||
const head3 = line.slice(i, i + 3).toLowerCase()
|
||||
const head4 = line.slice(i, i + 4).toLowerCase()
|
||||
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
||||
if (head4 === '</o>') { flushWord(); tokens.push({type: 'anchor', open: false}); i += 4; continue }
|
||||
|
||||
const c = line[i]
|
||||
const cc = line.charCodeAt(i)
|
||||
|
||||
if (cc === 0x1B) {
|
||||
// pre-existing ANSI escape - copy verbatim, zero visible width
|
||||
const m = line.indexOf('m', i)
|
||||
const end = (m < 0) ? line.length : m + 1
|
||||
buf += line.slice(i, end)
|
||||
i = end
|
||||
}
|
||||
else if (cc === 0x84) {
|
||||
// TSVM \u0084<digits>u escape - copy verbatim, one visible char
|
||||
const u = line.indexOf('u', i)
|
||||
const end = (u < 0) ? line.length : u + 1
|
||||
buf += line.slice(i, end)
|
||||
bufW += 1
|
||||
i = end
|
||||
}
|
||||
else if (c === ' ') {
|
||||
flushWord()
|
||||
tokens.push({type: 'sp'})
|
||||
i += 1
|
||||
}
|
||||
else if (cc === 0x00AD) {
|
||||
// soft hyphen: drop (no break-point handling for now)
|
||||
i += 1
|
||||
}
|
||||
else {
|
||||
buf += c
|
||||
bufW += 1
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
flushWord()
|
||||
return tokens
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Line builder
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Build wrapped lines from a token stream then format each one according to alignment.
|
||||
// Returns an array of strings, each exactly `width` visible chars wide (padded with
|
||||
// trailing spaces) so the caller can blit them without further math.
|
||||
function wrapAndAlign(tokens, width, alignment) {
|
||||
const lines = [] // each: {tokens, indent, contentW}
|
||||
let curTokens = []
|
||||
let curW = 0
|
||||
let curIndent = 0
|
||||
let nextIndent = 0 // indent the *next* flushed line should use
|
||||
|
||||
const flushLine = () => {
|
||||
// strip trailing soft spaces
|
||||
while (curTokens.length > 0 && curTokens[curTokens.length - 1].type === 'sp') {
|
||||
curTokens.pop()
|
||||
curW -= 1
|
||||
}
|
||||
lines.push({tokens: curTokens, indent: curIndent, contentW: curW})
|
||||
curTokens = []
|
||||
curW = 0
|
||||
curIndent = nextIndent
|
||||
}
|
||||
|
||||
for (const tok of tokens) {
|
||||
if (tok.type === 'anchor') {
|
||||
// anchor opens at the current visible column (accounting for indent)
|
||||
if (tok.open) nextIndent = curIndent + curW
|
||||
else nextIndent = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (tok.type === 'sp') {
|
||||
// ignore leading soft spaces on a fresh line
|
||||
if (curW === 0) continue
|
||||
// hard wrap if the line is already at the right edge
|
||||
if (curIndent + curW + 1 > width) { flushLine(); continue }
|
||||
curTokens.push(tok)
|
||||
curW += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// word
|
||||
const tw = tok.w
|
||||
if (curIndent + curW + tw > width) {
|
||||
flushLine()
|
||||
// word too wide for the wrapped line: emit it on its own row (possibly clipped by terminal)
|
||||
if (curIndent + tw > width) {
|
||||
curTokens.push(tok)
|
||||
curW += tw
|
||||
flushLine()
|
||||
continue
|
||||
}
|
||||
}
|
||||
curTokens.push(tok)
|
||||
curW += tw
|
||||
}
|
||||
|
||||
if (curTokens.length > 0 || lines.length === 0) flushLine()
|
||||
|
||||
return lines.map((line, i) => formatLine(line, width, alignment, i === lines.length - 1))
|
||||
}
|
||||
|
||||
function formatLine(line, totalWidth, alignment, isLast) {
|
||||
if (line.tokens.length === 0) return ' '.repeat(totalWidth)
|
||||
|
||||
const indent = ' '.repeat(line.indent)
|
||||
const remaining = totalWidth - line.indent - line.contentW
|
||||
const pad = (n) => (n > 0) ? ' '.repeat(n) : ''
|
||||
const flatText = () => line.tokens.map(t => (t.type === 'sp') ? ' ' : t.text).join('')
|
||||
|
||||
if (alignment === 'c') {
|
||||
const left = remaining >> 1
|
||||
return indent + pad(left) + flatText() + pad(remaining - left)
|
||||
}
|
||||
if (alignment === 'r') return indent + pad(remaining) + flatText()
|
||||
if (alignment === 'l') return indent + flatText() + pad(remaining)
|
||||
|
||||
// justified: only expand spaces when there's slack and we're not on the
|
||||
// last (or single) wrapped line
|
||||
if (isLast || remaining <= 0) return indent + flatText() + pad(remaining)
|
||||
|
||||
const spaceCount = line.tokens.reduce((n, t) => n + (t.type === 'sp' ? 1 : 0), 0)
|
||||
if (spaceCount === 0) return indent + flatText() + pad(remaining)
|
||||
|
||||
const baseExtra = (remaining / spaceCount) | 0
|
||||
let leftover = remaining - baseExtra * spaceCount
|
||||
|
||||
let out = indent
|
||||
for (const tok of line.tokens) {
|
||||
if (tok.type === 'sp') {
|
||||
const extra = baseExtra + (leftover > 0 ? 1 : 0)
|
||||
if (leftover > 0) leftover -= 1
|
||||
out += ' '.repeat(1 + extra)
|
||||
} else {
|
||||
out += tok.text
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Process a single source line: peel a leading <c>/<r>/<l> alignment tag (if present),
|
||||
// strip its matching close tag, then tokenise + wrap.
|
||||
function typesetSourceLine(line, width, defaultAlign) {
|
||||
if (line.length === 0) return [' '.repeat(width)]
|
||||
|
||||
let alignment = defaultAlign || 'j' // justified default
|
||||
const startMatch = line.match(/^<([crl])>/i)
|
||||
if (startMatch) {
|
||||
alignment = startMatch[1].toLowerCase()
|
||||
line = line.slice(startMatch[0].length)
|
||||
const closeRe = new RegExp(`</${alignment}>$`, 'i')
|
||||
line = line.replace(closeRe, '')
|
||||
}
|
||||
|
||||
const tokens = tokenise(line)
|
||||
return wrapAndAlign(tokens, width, alignment)
|
||||
}
|
||||
|
||||
function typesetText(text, width, defaultAlign) {
|
||||
text = expandEntities(text)
|
||||
const out = []
|
||||
for (const srcLine of text.split('\n')) {
|
||||
for (const outLine of typesetSourceLine(srcLine, width, defaultAlign)) out.push(outLine)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Convenience entry: `typeset(text)` defaults the wrap width to "rest of current row".
|
||||
// `opts` may be `{ defaultAlign: 'l' | 'c' | 'r' | 'j' }`.
|
||||
function typeset(text, customWidth, opts) {
|
||||
let typesetWidth = customWidth
|
||||
if (typesetWidth === undefined) {
|
||||
const SCRW = con.getmaxyx()[1]
|
||||
const currentPosX = con.getyx()[1] // 1-indexed
|
||||
typesetWidth = SCRW - currentPosX + 1
|
||||
}
|
||||
let defaultAlign = (opts && opts.defaultAlign) || 'j'
|
||||
return typesetText(text, typesetWidth, defaultAlign)
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Module exports
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
exports = {
|
||||
typeset,
|
||||
typesetText,
|
||||
typesetSourceLine,
|
||||
tokenise,
|
||||
expandEntities,
|
||||
fgEsc,
|
||||
COL_TEXT,
|
||||
COL_EMPH,
|
||||
COL_BRAND,
|
||||
COL_BRAND_DIM,
|
||||
ESC_DEFAULT,
|
||||
ESC_EMPH,
|
||||
MICROTONE,
|
||||
}
|
||||
@@ -65,12 +65,12 @@ class WindowObject {
|
||||
}
|
||||
if (this.titleRight !== undefined) {
|
||||
let tt = ''+this.titleRight
|
||||
con.move(this.y, this.x + this.width - tt.length - 2)
|
||||
con.move(this.y + this.height - 1, this.x + this.width - tt.length - 2)
|
||||
print(`\x84${charset[4]}u`)
|
||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`)
|
||||
print(`\x1B[38;5;${colourText}m${tt}`)
|
||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||
print(`\x1B[38;5;${colour}m\x84${charset[1]}u`)
|
||||
print(`\x1B[38;5;${colour}m\x84${charset[3]}u`)
|
||||
}
|
||||
|
||||
|
||||
@@ -180,4 +180,769 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
|
||||
return [currentCursorPos, currentScrollPos]
|
||||
}
|
||||
|
||||
exports = { WindowObject, scrollVert, scrollHorz }
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal dialog with optional body text, input fields, a scrollable selection
|
||||
// list, and OK/Cancel-style buttons. Layout from top to bottom:
|
||||
// title bar, message, fields, list, buttons.
|
||||
//
|
||||
// opts = {
|
||||
// title: string,
|
||||
// message: string | string[]?, -- optional body text drawn above fields/list
|
||||
// drawFrame: function(wo)?, -- override for the window-frame painter;
|
||||
// same contract as WindowObject's
|
||||
// `drawFrame` slot. Useful when the caller
|
||||
// wants its own border / title styling.
|
||||
//
|
||||
// fields: [{label, initial?, width, maxLength?}, ...] -- omit / [] for no input
|
||||
// field. Label does NOT get auto-colon.
|
||||
// `maxLength` caps insertable chars
|
||||
// (default: width * 4).
|
||||
//
|
||||
// list: { -- optional vertical selection list
|
||||
// items: [{label, ...}, ...], -- arbitrary user objects; only `label`
|
||||
// is read by the default renderer.
|
||||
// height: number, -- visible row count.
|
||||
// width: number?, -- inner width override (default: popup w-4).
|
||||
// cursor: number?, -- initial cursor row (default: first selectable).
|
||||
// selectable: function(item, i)->bool?, -- default: every item selectable. Non-
|
||||
// selectable rows are skipped by arrow keys.
|
||||
// When NO row is selectable, arrow / PgUp
|
||||
// / PgDn scroll the view instead.
|
||||
// renderItem: function(ctx)?, -- per-row painter; ctx exposes
|
||||
// { y, x, w, item, idx, isCursor, focused,
|
||||
// listBg, selBg, fg, hlFg, dimFg }.
|
||||
// Default prints `item.label`.
|
||||
// onActivate: function(item, i, key)?, -- fired on Enter ('\n') / Space (' ')
|
||||
// / left-click ('click'); return an
|
||||
// action string to close the dialog,
|
||||
// or null to stay open.
|
||||
// showScrollbar: bool?, -- default: auto (true when overflowing).
|
||||
// scrollbarChars: number[6]?, -- glyph codes for the scrollbar:
|
||||
// [troughTopEmpty, troughMidEmpty,
|
||||
// troughBotEmpty, troughTopFilled,
|
||||
// troughMidFilled, troughBotFilled].
|
||||
// Default [0xBA,0xBA,0xBA,0xDB,0xDB,0xDB]
|
||||
// (CP437-safe). Callers with a custom
|
||||
// charset (e.g. taut) pass their own.
|
||||
// drawWell: bool?, -- draw the list background
|
||||
// bg: number?, -- list background colour (default 242).
|
||||
// },
|
||||
//
|
||||
// buttons: [{label, action, default?}, ...] -- defaults to [OK, Cancel] (+ Delete
|
||||
// if `allowDelete:true`)
|
||||
// allowDelete: bool, -- inserts a Delete button (fsh compat)
|
||||
// colours: {fg?, bg?, fieldBg?, dimFg?, hlFg?, focusBg?, listBg?, listSelBg?}
|
||||
// -- per-call overrides
|
||||
// disableKeyRepeat: bool, -- when true, key won't repeat when held down
|
||||
// onKey: function(ks, shiftDown, ctx)?, -- escape hatch for callers that need
|
||||
// extra key bindings. Runs BEFORE the
|
||||
// built-in handlers. Return true to
|
||||
// consume the key. `ctx` exposes
|
||||
// { render, close(result),
|
||||
// getListCursor, setListCursor }.
|
||||
// }
|
||||
//
|
||||
// Returns {action, values, listCursor, listItem}: `action` is the chosen button's
|
||||
// `action` or the value returned from `onActivate` (default "ok"/"cancel"/"delete"),
|
||||
// or "cancel" on Esc; `values` is the array of field strings in field order;
|
||||
// `listCursor` is the final cursor index (-1 if there is no list); `listItem` is
|
||||
// the item at that index.
|
||||
//
|
||||
// Behaviour:
|
||||
// - Tab / Shift+Tab and arrow Down / Up cycle focus across fields, list, and buttons.
|
||||
// Inside the list, arrow Up / Down move the cursor between selectable rows;
|
||||
// PgUp/PgDn move a page; Home/End jump to the first/last selectable row.
|
||||
// - Left / Right inside a field move the caret; on the list or a button they cycle focus.
|
||||
// - Home / End jump to start / end of the focused field.
|
||||
// - Enter on a field jumps to the next field, then to the first button. Enter
|
||||
// or Space on a button activates it. Enter or Space on a list row invokes
|
||||
// `onActivate(item, idx, key)`; if that returns a string, the dialog closes
|
||||
// with that action.
|
||||
// - Insert at caret. Backspace deletes left of caret; Forward-Del deletes right.
|
||||
// - Blinking caret (`con.curs_set(1)`) is positioned on the focused field and
|
||||
// hidden when the list or a button has focus.
|
||||
// - Mouse: left-click on a button activates it; click on a field puts focus
|
||||
// on that field and positions the caret under the click; click on a list row
|
||||
// moves the cursor (and fires `onActivate` if defined); mouse-wheel inside the
|
||||
// list scrolls it. Mouse hover on a button moves focus to it (the same focus
|
||||
// the keyboard uses).
|
||||
const _dialogScreen = con.getmaxyx()
|
||||
const _dialogPixDim = graphics.getPixelDimension()
|
||||
const _CELL_PW = (_dialogPixDim[0] / _dialogScreen[1]) | 0
|
||||
const _CELL_PH = (_dialogPixDim[1] / _dialogScreen[0]) | 0
|
||||
function _pxToCell(px, py) { return [(py / _CELL_PH | 0) + 1, (px / _CELL_PW | 0) + 1] }
|
||||
|
||||
function showDialog(opts) {
|
||||
const fields = opts.fields || []
|
||||
const values = fields.map(f => (f.initial == null) ? '' : ('' + f.initial))
|
||||
const cursors = values.map(v => v.length)
|
||||
|
||||
let oldFG = con.get_color_fore()
|
||||
let oldBG = con.get_color_back()
|
||||
|
||||
let buttons
|
||||
if (opts.buttons) {
|
||||
buttons = opts.buttons
|
||||
} else {
|
||||
buttons = [{label: 'OK', action: 'ok', default: true}]
|
||||
if (opts.allowDelete) buttons.push({label: 'Delete', action: 'delete'})
|
||||
buttons.push({label: 'Cancel', action: 'cancel'})
|
||||
}
|
||||
|
||||
const title = opts.title || ''
|
||||
const message = opts.message
|
||||
const messageLines = !message ? []
|
||||
: Array.isArray(message) ? message
|
||||
: ('' + message).split('\n')
|
||||
|
||||
const list = opts.list || null
|
||||
const drawWell = list?.drawWell ?? true
|
||||
const c = opts.colours || {}
|
||||
const fg = (c.fg != null) ? c.fg : 254
|
||||
const bg = (c.bg != null) ? c.bg : 244
|
||||
const fieldBg = (c.fieldBg != null) ? c.fieldBg : 240
|
||||
const dimFg = (c.dimFg != null) ? c.dimFg : 249
|
||||
const hlFg = (c.hlFg != null) ? c.hlFg : 240
|
||||
const focusBg = (c.focusBg != null) ? c.focusBg : 253
|
||||
const listBg = (c.listBg != null) ? c.listBg : (drawWell) ? 243 : bg
|
||||
const listSelBg = (c.listSelBg != null) ? c.listSelBg : focusBg
|
||||
|
||||
// List state
|
||||
const listItems = list ? (list.items || []) : []
|
||||
const listSelectable = list && list.selectable ? list.selectable : (() => true)
|
||||
const listHeight = list ? (list.height || Math.min(8, listItems.length)) : 0
|
||||
const hasList = !!list
|
||||
const listOnActivate = list ? list.onActivate : null
|
||||
const listBgColour = (list && list.bg != null) ? list.bg : listBg
|
||||
// Scrollbar glyphs: [trough top/mid/bottom empty, then top/mid/bottom filled].
|
||||
// Default is CP437-safe (0xBA track, 0xDB thumb); callers with their own
|
||||
// charset (e.g. taut's 0xBA..0xBF) pass a 6-item override.
|
||||
const listScrollbarChars = (list && Array.isArray(list.scrollbarChars) && list.scrollbarChars.length >= 6)
|
||||
? list.scrollbarChars
|
||||
: [0xBA, 0xBA, 0xBA, 0xDB, 0xDB, 0xDB]
|
||||
function firstSelectable(from, dir) {
|
||||
if (!hasList || listItems.length === 0) return -1
|
||||
let i = from
|
||||
for (let n = 0; n < listItems.length; n++) {
|
||||
if (i >= 0 && i < listItems.length && listSelectable(listItems[i], i)) return i
|
||||
i += dir
|
||||
if (i < 0) i = listItems.length - 1
|
||||
if (i >= listItems.length) i = 0
|
||||
}
|
||||
return -1
|
||||
}
|
||||
let listCursor = hasList
|
||||
? (list.cursor != null ? list.cursor : firstSelectable(0, +1))
|
||||
: -1
|
||||
let listScroll = 0
|
||||
|
||||
// Layout
|
||||
const buttonGap = 3
|
||||
const maxFieldW = fields.reduce((m, f) => Math.max(m, f.width), 16)
|
||||
const longestMsg = messageLines.reduce((m, l) => Math.max(m, l.length), 0)
|
||||
// When the caller pins `list.width`, trust it — string `.length` overcounts
|
||||
// visual width whenever items embed ANSI escapes or TVDOS \x84NNu sequences
|
||||
// (e.g. taut's help popup, whose rows are pre-typeset with fg-colour escapes).
|
||||
const longestItem = hasList && list.width == null
|
||||
? listItems.reduce((m, it) => Math.max(m, (it.label || '').length), 0)
|
||||
: 0
|
||||
const titleW = title.length + 4
|
||||
const btnRowW = buttons.reduce((s, b) => s + b.label.length + 4, 0) + buttonGap * Math.max(0, buttons.length - 1)
|
||||
const listMinW = hasList
|
||||
? (list.width != null ? list.width + 4 : longestItem + 6)
|
||||
: 0
|
||||
const w = 2+Math.max(maxFieldW + 6, titleW + 4, longestMsg + 6, btnRowW + 4, listMinW, 22)
|
||||
|
||||
const msgRows = messageLines.length + (messageLines.length > 0 ? 1 : 0)
|
||||
const fieldsBlockH = fields.length * 4
|
||||
const listBlockH = hasList ? listHeight + 2 : 0 // top border + rows + bottom border
|
||||
|
||||
let bodyRows = msgRows
|
||||
if (fields.length > 0) bodyRows += fieldsBlockH + 1 // +1 spacing after fields
|
||||
if (hasList) bodyRows += listBlockH + 1 // +1 spacing after list
|
||||
if (bodyRows === 0) bodyRows = 1 // at least one row above buttons
|
||||
const buttonsRowOff = 1 + bodyRows
|
||||
const h = buttonsRowOff + 2
|
||||
|
||||
const screen = con.getmaxyx()
|
||||
const row = Math.max(2, Math.floor((screen[0] - h) / 2))
|
||||
const col = Math.max(2, Math.floor((screen[1] - w) / 2))
|
||||
|
||||
// Focus layout: 0..fields.length-1 = fields, [+1 = list if present], then buttons.
|
||||
const listFocusIdx = hasList ? fields.length : -1
|
||||
const buttonsFocusBase = fields.length + (hasList ? 1 : 0)
|
||||
const totalFocus = buttonsFocusBase + buttons.length
|
||||
|
||||
// Pick initial focus: explicit default > list > first field > first button.
|
||||
let focusIdx = -1
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
if (buttons[i].default) { focusIdx = buttonsFocusBase + i; break }
|
||||
}
|
||||
if (focusIdx < 0) {
|
||||
if (fields.length > 0) focusIdx = 0
|
||||
else if (hasList) focusIdx = listFocusIdx
|
||||
else focusIdx = buttonsFocusBase
|
||||
}
|
||||
let done = null
|
||||
|
||||
function fieldScroll(cur, fw) { return cur < fw ? 0 : cur - fw + 1 }
|
||||
function fieldLabelRow(i) { return row + 1 + msgRows + i * 4 }
|
||||
function fieldBoxRow(i) { return fieldLabelRow(i) + 1 }
|
||||
function fieldContentRow(i) { return fieldLabelRow(i) + 2 }
|
||||
function fieldBoxCol() { return col + 2 }
|
||||
function fieldContentRegion(i) { return { x: fieldBoxCol() + 1, y: fieldContentRow(i), w: fields[i].width } }
|
||||
|
||||
function listBlockTopRow() {
|
||||
return row + 1 + msgRows + (fields.length > 0 ? fieldsBlockH + 1 : 0)
|
||||
}
|
||||
function listBlockCol() { return col + 2 }
|
||||
function listBlockWidth() { return w - 4 } // inner content width incl. borders
|
||||
function listContentRow(i) { return listBlockTopRow() + 1 + (i - listScroll) }
|
||||
function listContentCol() { return listBlockCol() + 1 }
|
||||
function listScrollbarNeeded() {
|
||||
if (!hasList) return false
|
||||
if (list.showScrollbar != null) return list.showScrollbar
|
||||
return listItems.length > listHeight
|
||||
}
|
||||
function listContentInnerW() {
|
||||
return listBlockWidth() - 2 - (listScrollbarNeeded() ? 1 : 0)
|
||||
}
|
||||
|
||||
function buttonRegions() {
|
||||
let bx = col + Math.floor((w - btnRowW) / 2)
|
||||
return buttons.map(b => {
|
||||
const r = { x: bx, y: row + buttonsRowOff, w: b.label.length + 4 }
|
||||
bx += b.label.length + 4 + buttonGap
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
function drawFrameBox() {
|
||||
con.color_pair(fg, bg)
|
||||
for (let r = row; r < row + h; r++) {
|
||||
con.move(r, col)
|
||||
print(' '.repeat(w))
|
||||
}
|
||||
const wo = new WindowObject(col, row, w, h, ()=>{}, ()=>{}, title, opts.drawFrame)
|
||||
wo.isHighlighted = true
|
||||
wo.titleBack = bg
|
||||
wo.drawFrame()
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
|
||||
function drawMessage() {
|
||||
if (messageLines.length === 0) return
|
||||
con.color_pair(fg, bg)
|
||||
for (let i = 0; i < messageLines.length; i++) {
|
||||
con.move(row + 1 + i, col + 2)
|
||||
print(messageLines[i].padEnd(w - 4, ' '))
|
||||
}
|
||||
}
|
||||
|
||||
function drawField(i) {
|
||||
const f = fields[i]
|
||||
const fbCol = fieldBoxCol()
|
||||
const fbRow = fieldBoxRow(i)
|
||||
const fw = f.width
|
||||
const focused = (focusIdx === i)
|
||||
const frameFg = focused ? fg : dimFg
|
||||
|
||||
// Label
|
||||
con.color_pair(fg, bg)
|
||||
con.move(fieldLabelRow(i), fbCol)
|
||||
print(f.label)
|
||||
|
||||
// Top border (3px padding w/ TSVM chr rom)
|
||||
con.color_pair(fieldBg, bg)
|
||||
con.move(fbRow, fbCol)
|
||||
print('\u00EC' + '\u00A9'.repeat(fw) + '\u00ED')
|
||||
|
||||
// Left border (3px padding w/ TSVM chr rom)
|
||||
con.move(fbRow + 1, fbCol)
|
||||
print('\u00AB')
|
||||
|
||||
// the content
|
||||
con.color_pair(fg, fieldBg)
|
||||
const s = fieldScroll(cursors[i], fw)
|
||||
const vis = values[i].substring(s, s + fw)
|
||||
print(vis.padEnd(fw, ' '))
|
||||
|
||||
// Right border (3px padding w/ TSVM chr rom)
|
||||
con.color_pair(fieldBg, bg)
|
||||
con.move(fbRow + 1, fbCol + fw + 1)
|
||||
print('\u00AA')
|
||||
|
||||
// Bottom border (3px padding w/ TSVM chr rom)
|
||||
con.move(fbRow + 2, fbCol)
|
||||
print('\u00F4' + '\u00AC'.repeat(fw) + '\u00F5')
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
|
||||
function drawList() {
|
||||
if (!hasList) return
|
||||
const lbCol = listBlockCol()
|
||||
const lbRow = listBlockTopRow()
|
||||
const lw = listBlockWidth()
|
||||
const innerW = listContentInnerW()
|
||||
const focused = (focusIdx === listFocusIdx)
|
||||
const frameFg = focused ? fg : dimFg
|
||||
const sbar = listScrollbarNeeded()
|
||||
|
||||
// Top border (drawField style)
|
||||
if (drawWell) {
|
||||
con.color_pair(listBgColour, bg)
|
||||
con.move(lbRow, lbCol)
|
||||
print('\u00EC' + '\u00A9'.repeat(lw - 2) + '\u00ED')
|
||||
}
|
||||
|
||||
// Side borders + rows
|
||||
for (let r = 0; r < listHeight; r++) {
|
||||
if (drawWell) {
|
||||
con.color_pair(listBgColour, bg)
|
||||
con.move(lbRow + 1 + r, lbCol)
|
||||
print('\u00AB')
|
||||
con.move(lbRow + 1 + r, lbCol + lw - 1)
|
||||
print('\u00AA')
|
||||
}
|
||||
|
||||
const idx = listScroll + r
|
||||
con.move(lbRow + 1 + r, lbCol + 1)
|
||||
if (idx >= listItems.length) {
|
||||
con.color_pair(fg, listBgColour)
|
||||
print(' '.repeat(innerW))
|
||||
continue
|
||||
}
|
||||
const it = listItems[idx]
|
||||
const isCursor = (idx === listCursor)
|
||||
const ctx = {
|
||||
y: lbRow + 1 + r,
|
||||
x: lbCol + 1,
|
||||
w: innerW,
|
||||
item: it,
|
||||
idx: idx,
|
||||
isCursor: isCursor,
|
||||
focused: focused,
|
||||
listBg: listBgColour,
|
||||
selBg: listSelBg,
|
||||
fg: fg,
|
||||
hlFg: hlFg,
|
||||
dimFg: dimFg,
|
||||
}
|
||||
if (list.renderItem) {
|
||||
list.renderItem(ctx)
|
||||
} else {
|
||||
const useFg = (isCursor && focused) ? hlFg : fg
|
||||
const useBg = (isCursor && focused) ? listSelBg : listBgColour
|
||||
con.color_pair(useFg, useBg)
|
||||
const label = (it.label || '').substring(0, innerW - 1)
|
||||
print(' ' + label.padEnd(innerW - 1, ' '))
|
||||
}
|
||||
|
||||
// Scrollbar column
|
||||
if (sbar) {
|
||||
con.color_pair(dimFg, listBgColour)
|
||||
con.move(lbRow + 1 + r, lbCol + lw - 2)
|
||||
const maxScroll = Math.max(1, listItems.length - listHeight)
|
||||
const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0)
|
||||
// seg: 0 = top cap, 1 = middle, 2 = bottom cap; +3 selects the
|
||||
// filled (thumb) variant over the empty (trough) one.
|
||||
const seg = (r === 0) ? 0 : (r === listHeight - 1) ? 2 : 1
|
||||
con.addch(listScrollbarChars[(r === indPos) ? seg + 3 : seg])
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
if (drawWell) {
|
||||
con.color_pair(listBgColour, bg)
|
||||
con.move(lbRow + 1 + listHeight, lbCol)
|
||||
print('\u00F4' + '\u00AC'.repeat(lw - 2) + '\u00F5')
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
}
|
||||
|
||||
function drawButton(i, regions) {
|
||||
const b = buttons[i]
|
||||
const bIdx = buttonsFocusBase + i
|
||||
const focused = (focusIdx === bIdx)
|
||||
const r = regions[i]
|
||||
const useFg = focused ? hlFg : fg
|
||||
const useBg = focused ? focusBg : bg
|
||||
con.color_pair(useFg, useBg)
|
||||
con.move(r.y, r.x-1)
|
||||
if (focused) {
|
||||
con.color_pair(useBg, bg)
|
||||
print('\u00DE')
|
||||
con.color_pair(useFg, useBg)
|
||||
print('[ ' + b.label + ' ]')
|
||||
con.color_pair(useBg, bg)
|
||||
print('\u00DD')
|
||||
}
|
||||
else
|
||||
print(' [ ' + b.label + ' ] ')
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
|
||||
function positionCaret() {
|
||||
if (focusIdx < fields.length) {
|
||||
const fw = fields[focusIdx].width
|
||||
const s = fieldScroll(cursors[focusIdx], fw)
|
||||
con.move(fieldContentRow(focusIdx), fieldBoxCol() + 1 + (cursors[focusIdx] - s))
|
||||
con.curs_set(1)
|
||||
} else {
|
||||
con.curs_set(0)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureListCursorVisible() {
|
||||
if (!hasList) return
|
||||
if (listCursor < 0) return
|
||||
if (listCursor < listScroll) listScroll = listCursor
|
||||
else if (listCursor >= listScroll + listHeight) listScroll = listCursor - listHeight + 1
|
||||
const maxScroll = Math.max(0, listItems.length - listHeight)
|
||||
if (listScroll > maxScroll) listScroll = maxScroll
|
||||
if (listScroll < 0) listScroll = 0
|
||||
}
|
||||
|
||||
function scrollListBy(dir) {
|
||||
const maxScroll = Math.max(0, listItems.length - listHeight)
|
||||
let s = listScroll + dir
|
||||
if (s < 0) s = 0
|
||||
if (s > maxScroll) s = maxScroll
|
||||
listScroll = s
|
||||
}
|
||||
|
||||
function moveListCursor(dir) {
|
||||
if (!hasList || listItems.length === 0) return
|
||||
// Scroll the view when nothing in the list is selectable (e.g. a help text body).
|
||||
if (listCursor < 0) { scrollListBy(dir); return }
|
||||
let next = listCursor
|
||||
for (let n = 0; n < listItems.length; n++) {
|
||||
next += dir
|
||||
if (next < 0 || next >= listItems.length) return
|
||||
if (listSelectable(listItems[next], next)) {
|
||||
listCursor = next
|
||||
ensureListCursorVisible()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pageListCursor(dir) {
|
||||
if (!hasList || listItems.length === 0) return
|
||||
if (listCursor < 0) { scrollListBy(dir * listHeight); return }
|
||||
let target = listCursor + dir * listHeight
|
||||
if (target < 0) target = 0
|
||||
if (target >= listItems.length) target = listItems.length - 1
|
||||
// Snap to nearest selectable
|
||||
let probe = target
|
||||
const step = dir < 0 ? -1 : 1
|
||||
while (probe >= 0 && probe < listItems.length && !listSelectable(listItems[probe], probe)) probe += step
|
||||
if (probe < 0 || probe >= listItems.length) probe = firstSelectable(target, -step)
|
||||
if (probe >= 0) { listCursor = probe; ensureListCursorVisible() }
|
||||
}
|
||||
|
||||
function render() {
|
||||
drawFrameBox()
|
||||
drawMessage()
|
||||
for (let i = 0; i < fields.length; i++) drawField(i)
|
||||
drawList()
|
||||
const regs = buttonRegions()
|
||||
for (let i = 0; i < buttons.length; i++) drawButton(i, regs)
|
||||
positionCaret()
|
||||
}
|
||||
|
||||
function moveFocus(dir) {
|
||||
focusIdx = (focusIdx + dir + totalFocus) % totalFocus
|
||||
render()
|
||||
}
|
||||
|
||||
function activateButton(i) {
|
||||
done = {
|
||||
action: buttons[i].action,
|
||||
values: values.slice(),
|
||||
listCursor: listCursor,
|
||||
listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null,
|
||||
}
|
||||
}
|
||||
|
||||
function activateListItem(idx, key) {
|
||||
if (!hasList || !listOnActivate) return false
|
||||
if (idx < 0 || idx >= listItems.length) return false
|
||||
if (!listSelectable(listItems[idx], idx)) return false
|
||||
const result = listOnActivate(listItems[idx], idx, key)
|
||||
if (result == null) {
|
||||
// Callback consumed the event but kept the dialog open (e.g. radio
|
||||
// toggle); reflect any state changes it made.
|
||||
render()
|
||||
return true
|
||||
}
|
||||
done = {
|
||||
action: result,
|
||||
values: values.slice(),
|
||||
listCursor: idx,
|
||||
listItem: listItems[idx],
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function hitTestMouse(ev) {
|
||||
const cell = _pxToCell(ev[1], ev[2])
|
||||
const cy = cell[0], cx = cell[1]
|
||||
const btnRegs = buttonRegions()
|
||||
for (let i = 0; i < btnRegs.length; i++) {
|
||||
const r = btnRegs[i]
|
||||
if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'button', idx: i }
|
||||
}
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const r = fieldContentRegion(i)
|
||||
if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'field', idx: i, cx: cx, region: r }
|
||||
}
|
||||
if (hasList) {
|
||||
const lbRow = listBlockTopRow()
|
||||
const lbCol = listBlockCol()
|
||||
const innerW = listContentInnerW()
|
||||
if (cy > lbRow && cy <= lbRow + listHeight && cx >= lbCol + 1 && cx < lbCol + 1 + innerW) {
|
||||
const r = cy - (lbRow + 1)
|
||||
const idx = listScroll + r
|
||||
if (idx >= 0 && idx < listItems.length) return { kind: 'list', idx: idx }
|
||||
}
|
||||
if (cy > lbRow && cy <= lbRow + listHeight && cx >= lbCol && cx < lbCol + listBlockWidth()) {
|
||||
return { kind: 'listblank' }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const externalCtx = {
|
||||
render: () => render(),
|
||||
close: (result) => {
|
||||
done = Object.assign({
|
||||
action: 'cancel',
|
||||
values: values.slice(),
|
||||
listCursor: listCursor,
|
||||
listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null,
|
||||
}, result || {})
|
||||
},
|
||||
getListCursor: () => listCursor,
|
||||
setListCursor: (n) => {
|
||||
if (!hasList) return
|
||||
if (n < 0 || n >= listItems.length) return
|
||||
listCursor = n
|
||||
ensureListCursorVisible()
|
||||
},
|
||||
}
|
||||
|
||||
ensureListCursorVisible()
|
||||
render()
|
||||
|
||||
let eventJustReceived = true
|
||||
while (done === null) {
|
||||
input.withEvent(ev => {
|
||||
if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) {
|
||||
eventJustReceived = false; return
|
||||
}
|
||||
|
||||
if (ev[0] === 'mouse_move') {
|
||||
const hit = hitTestMouse(ev)
|
||||
if (hit && hit.kind === 'button') {
|
||||
const newFocus = buttonsFocusBase + hit.idx
|
||||
if (newFocus !== focusIdx) {
|
||||
focusIdx = newFocus
|
||||
render()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev[0] === 'mouse_down') {
|
||||
if (ev[3] !== 1) return
|
||||
const hit = hitTestMouse(ev)
|
||||
if (!hit) return
|
||||
if (hit.kind === 'button') {
|
||||
focusIdx = buttonsFocusBase + hit.idx
|
||||
render()
|
||||
activateButton(hit.idx)
|
||||
return
|
||||
}
|
||||
if (hit.kind === 'field') {
|
||||
focusIdx = hit.idx
|
||||
const fw = fields[hit.idx].width
|
||||
const s = fieldScroll(cursors[hit.idx], fw)
|
||||
const newCur = s + (hit.cx - hit.region.x)
|
||||
cursors[hit.idx] = Math.min(values[hit.idx].length, Math.max(0, newCur))
|
||||
render()
|
||||
return
|
||||
}
|
||||
if (hit.kind === 'list') {
|
||||
focusIdx = listFocusIdx
|
||||
if (listSelectable(listItems[hit.idx], hit.idx)) {
|
||||
listCursor = hit.idx
|
||||
ensureListCursorVisible()
|
||||
render()
|
||||
if (activateListItem(hit.idx, 'click')) return
|
||||
} else {
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (hit.kind === 'listblank') {
|
||||
focusIdx = listFocusIdx
|
||||
render()
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev[0] === 'mouse_wheel' && hasList) {
|
||||
const hit = hitTestMouse(ev)
|
||||
if (!hit || (hit.kind !== 'list' && hit.kind !== 'listblank')) return
|
||||
const dy = (ev[3] | 0) * 3
|
||||
const maxScroll = Math.max(0, listItems.length - listHeight)
|
||||
let next = listScroll + dy
|
||||
if (next < 0) next = 0
|
||||
if (next > maxScroll) next = maxScroll
|
||||
if (next !== listScroll) { listScroll = next; render() }
|
||||
return
|
||||
}
|
||||
if (ev[0] !== 'key_down') return
|
||||
if (opts.disableKeyRepeat && 1 !== ev[2]) return
|
||||
const ks = ev[1]
|
||||
const shiftDown = (ev.includes(59) || ev.includes(60))
|
||||
|
||||
if (opts.onKey && opts.onKey(ks, shiftDown, externalCtx)) return
|
||||
|
||||
if (ks === '<ESC>') {
|
||||
done = {
|
||||
action: 'cancel',
|
||||
values: values.slice(),
|
||||
listCursor: listCursor,
|
||||
listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (ks === '\t' || ks === '<TAB>') { moveFocus(shiftDown ? -1 : 1); return }
|
||||
|
||||
// Vertical movement: arrows operate within the list when it has focus.
|
||||
if (ks === '<UP>') {
|
||||
if (focusIdx === listFocusIdx) { moveListCursor(-1); render() }
|
||||
else moveFocus(-1)
|
||||
return
|
||||
}
|
||||
if (ks === '<DOWN>') {
|
||||
if (focusIdx === listFocusIdx) { moveListCursor(+1); render() }
|
||||
else moveFocus(+1)
|
||||
return
|
||||
}
|
||||
if (ks === '<PAGE_UP>') {
|
||||
if (focusIdx === listFocusIdx) { pageListCursor(-1); render() }
|
||||
return
|
||||
}
|
||||
if (ks === '<PAGE_DOWN>') {
|
||||
if (focusIdx === listFocusIdx) { pageListCursor(+1); render() }
|
||||
return
|
||||
}
|
||||
|
||||
if (ks === '<LEFT>') {
|
||||
if (focusIdx < fields.length) {
|
||||
if (cursors[focusIdx] > 0) { cursors[focusIdx] -= 1; render() }
|
||||
} else moveFocus(-1)
|
||||
return
|
||||
}
|
||||
if (ks === '<RIGHT>') {
|
||||
if (focusIdx < fields.length) {
|
||||
if (cursors[focusIdx] < values[focusIdx].length) { cursors[focusIdx] += 1; render() }
|
||||
} else moveFocus(+1)
|
||||
return
|
||||
}
|
||||
if (ks === '<HOME>') {
|
||||
if (focusIdx < fields.length) { cursors[focusIdx] = 0; render() }
|
||||
else if (focusIdx === listFocusIdx) {
|
||||
const t = firstSelectable(0, +1)
|
||||
if (t >= 0) { listCursor = t; ensureListCursorVisible(); render() }
|
||||
else { listScroll = 0; render() }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ks === '<END>') {
|
||||
if (focusIdx < fields.length) { cursors[focusIdx] = values[focusIdx].length; render() }
|
||||
else if (focusIdx === listFocusIdx) {
|
||||
const t = firstSelectable(listItems.length - 1, -1)
|
||||
if (t >= 0) { listCursor = t; ensureListCursorVisible(); render() }
|
||||
else { listScroll = Math.max(0, listItems.length - listHeight); render() }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (focusIdx < fields.length) {
|
||||
if (ks === '\n') {
|
||||
if (focusIdx < fields.length - 1) focusIdx = focusIdx + 1
|
||||
else if (hasList) focusIdx = listFocusIdx
|
||||
else focusIdx = buttonsFocusBase
|
||||
render()
|
||||
return
|
||||
}
|
||||
if (ks === '\x08') {
|
||||
const cur = cursors[focusIdx]
|
||||
if (cur > 0) {
|
||||
const v = values[focusIdx]
|
||||
values[focusIdx] = v.substring(0, cur - 1) + v.substring(cur)
|
||||
cursors[focusIdx] = cur - 1
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ks === '<DEL>') {
|
||||
const cur = cursors[focusIdx]
|
||||
const v = values[focusIdx]
|
||||
if (cur < v.length) {
|
||||
values[focusIdx] = v.substring(0, cur) + v.substring(cur + 1)
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (typeof ks === 'string' && ks.length === 1) {
|
||||
const code = ks.charCodeAt(0)
|
||||
const cap = fields[focusIdx].maxLength != null
|
||||
? fields[focusIdx].maxLength
|
||||
: fields[focusIdx].width * 4
|
||||
if (code >= 32 && code < 256 && values[focusIdx].length < cap) {
|
||||
const v = values[focusIdx]
|
||||
const cur = cursors[focusIdx]
|
||||
values[focusIdx] = v.substring(0, cur) + ks + v.substring(cur)
|
||||
cursors[focusIdx] = cur + 1
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
} else if (focusIdx === listFocusIdx) {
|
||||
if (ks === '\n' || ks === ' ') {
|
||||
if (listCursor >= 0 && activateListItem(listCursor, ks)) return
|
||||
}
|
||||
} else {
|
||||
if (ks === '\n' || ks === ' ') { activateButton(focusIdx - buttonsFocusBase); return }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Modal-dialog convention: wait for the user to release whatever key closed
|
||||
// the dialog before handing control back. TVDOS's input strobo
|
||||
// (TVDOS.SYS:input.withEvent) keeps re-firing `key_down` for a held key
|
||||
// once its ~250 ms initial-press delay elapses; without this drain a brief
|
||||
// hold on Enter inside a popup would surface as a fresh Enter to whatever
|
||||
// the popup was covering, e.g. activating the file under zfm's More menu.
|
||||
// A mouse close (or any path with no key held) leaves the head key at 0
|
||||
// and skips the wait.
|
||||
sys.poke(-40, 255)
|
||||
const heldHead = sys.peek(-41)
|
||||
if (heldHead !== 0) {
|
||||
while (true) {
|
||||
input.withEvent(() => {})
|
||||
if (sys.peek(-41) !== heldHead) break
|
||||
}
|
||||
}
|
||||
|
||||
con.curs_set(0)
|
||||
con.color_pair(oldFG, oldBG)
|
||||
return done
|
||||
}
|
||||
|
||||
exports = { WindowObject, scrollVert, scrollHorz, showDialog }
|
||||
|
||||
561
assets/disk0/tvdos/sbin/vtmgr.js
Normal file
561
assets/disk0/tvdos/sbin/vtmgr.js
Normal file
@@ -0,0 +1,561 @@
|
||||
// vtmgr — virtual console manager for TVDOS
|
||||
//
|
||||
// Spawns up to 6 independent shell sessions (virtual consoles), each in its
|
||||
// own parallel GraalVM context with its own thread. Each pane runs a real
|
||||
// `command -fancy` shell. The dispatcher (this file) owns the physical
|
||||
// keyboard, polls Alt-N hotkeys at 30 Hz, blits the active pane's text
|
||||
// plane to the GPU's text area, and routes typed characters into the
|
||||
// active pane's input ring buffer.
|
||||
//
|
||||
// Hotkeys: Alt-1..Alt-6 switch to that VT (lazy-spawn on first use).
|
||||
// Alt-0 cleanly tears down vtmgr.
|
||||
// Builtins: `chvt N` from inside a pane writes to the switch register.
|
||||
|
||||
// ─── shared memory layout ───────────────────────────────────────────────────
|
||||
// CTRL_AREA (64 bytes from base)
|
||||
// +0 active_vt u8 (1..6)
|
||||
// +1 switch_request u8 (0 = none, 1..6 = target; set by chvt, cleared by dispatcher)
|
||||
// +2 debounce_held u8
|
||||
// +3 vt_spawned_bits u8 (bit n-1 set if VT n is alive)
|
||||
// +4..63 reserved
|
||||
// VT block (× MAX_VT) starting at base + 64, each VT_BLOCK_SIZE bytes
|
||||
// +0..7 reserved (cursor & color state lives inside text plane itself)
|
||||
// +8 queue_head u8 (next-read index)
|
||||
// +9 queue_tail u8 (next-write index)
|
||||
// +10..11 reserved
|
||||
// +12..267 queue_data (256-byte ring buffer; one slot lost to full/empty disambiguation)
|
||||
// +268..271 reserved (alignment)
|
||||
// +272..7953 text_plane (7682 bytes; mirrors GPU textArea layout exactly)
|
||||
|
||||
const MAX_VT = 6
|
||||
const CTRL_AREA_SIZE = 64
|
||||
const VT_BLOCK_SIZE = 8000
|
||||
const TEXT_PLANE_OFFSET = 272
|
||||
const TEXT_PLANE_SIZE = 7682
|
||||
const QUEUE_DATA_OFFSET = 12
|
||||
|
||||
const CTRL_ACTIVE_VT = 0
|
||||
const CTRL_SWITCH_REQUEST = 1
|
||||
const CTRL_DEBOUNCE_HELD = 2
|
||||
const CTRL_SPAWNED_BITS = 3
|
||||
|
||||
const GPU_TEXTAREA_OFFSET = 253950
|
||||
const TEXT_COLS = 80
|
||||
const TEXT_ROWS = 32
|
||||
|
||||
const TP_FORE_BASE = 2
|
||||
const TP_BACK_BASE = 2 + 2560
|
||||
const TP_TEXT_BASE = 2 + 2560 + 2560
|
||||
|
||||
const TOTAL_ALLOC_SIZE = CTRL_AREA_SIZE + MAX_VT * VT_BLOCK_SIZE
|
||||
const BASE = sys.malloc(TOTAL_ALLOC_SIZE)
|
||||
if (!BASE || BASE === 0) { printerrln("vtmgr: sys.malloc failed"); return 1 }
|
||||
for (let i = 0; i < TOTAL_ALLOC_SIZE; i++) sys.poke(BASE + i, 0)
|
||||
|
||||
const CTRL = BASE
|
||||
function vtBlockAddr(n) { return BASE + CTRL_AREA_SIZE + (n - 1) * VT_BLOCK_SIZE }
|
||||
function vtTextPlaneAddr(n) { return vtBlockAddr(n) + TEXT_PLANE_OFFSET }
|
||||
|
||||
// ─── pane bootstrap ─────────────────────────────────────────────────────────
|
||||
// Read TVDOS.SYS once at startup. Each pane's bootstrap embeds the source
|
||||
// (via JSON.stringify-escaped string literal) and evaluates it together with
|
||||
// the shell-start code as ONE direct-eval call. This matters because strict-
|
||||
// mode direct eval is scope-isolated; if TVDOS.SYS and the shell launcher
|
||||
// were two separate evals, the shell launcher wouldn't see `_TVDOS`,
|
||||
// `files`, `execApp`, etc. defined by the first eval.
|
||||
|
||||
const TVDOS_SYS_SRC = files.open("A:/tvdos/TVDOS.SYS").sread()
|
||||
|
||||
// _BIOS is set by the real BIOS before TVDOS.SYS runs; TVDOS.SYS reads
|
||||
// _BIOS.FIRST_BOOTABLE_PORT during init. Each pane is a fresh context with no
|
||||
// BIOS, so capture the live value here (vtmgr runs in the main context where
|
||||
// _BIOS is visible) and re-declare it in every pane bootstrap.
|
||||
const BIOS_FIRST_BOOTABLE_PORT = JSON.stringify(_BIOS.FIRST_BOOTABLE_PORT)
|
||||
|
||||
// Environment no longer needs snapshotting/replaying: each pane re-evaluates
|
||||
// TVDOS.SYS, whose boot block runs \commandrc in every context, so the pane
|
||||
// gets the same PATH / KEYBOARD / etc. natively. The pane then runs
|
||||
// \AUTOEXEC.BAT (the per-console launch script: IME + interactive shell).
|
||||
|
||||
function makePaneBootstrap(vtNum) {
|
||||
const TP_BASE = vtTextPlaneAddr(vtNum)
|
||||
const VT_BLK = vtBlockAddr(vtNum)
|
||||
|
||||
// Launcher code runs after TVDOS.SYS in the SAME eval scope, so `files`,
|
||||
// `eval`, `_TVDOS` etc. resolve via lexical closure. TVDOS.SYS's boot
|
||||
// block already ran \commandrc (env) and skipped its own AUTOEXEC because
|
||||
// the pane sets _TVDOS_IS_VT_PANE; here we run \AUTOEXEC.BAT to launch the
|
||||
// per-console shell.
|
||||
const SHELL_START = ";\n"
|
||||
+ "var _cmdfileSrc = files.open('A:/tvdos/bin/command.js').sread();\n"
|
||||
+ "eval('var _VTSHELL=function(exec_args){' + _cmdfileSrc + '\\n};_VTSHELL')(['', '-c', '\\\\AUTOEXEC.BAT']);\n"
|
||||
|
||||
const combined = TVDOS_SYS_SRC + SHELL_START
|
||||
|
||||
const raw = `
|
||||
globalThis.VT_NUM = ${vtNum}
|
||||
globalThis.VT_TEXT_PLANE = ${TP_BASE}
|
||||
globalThis.VT_BLOCK_ADDR = ${VT_BLK}
|
||||
globalThis.VT_CTRL_ADDR = ${CTRL}
|
||||
const TP = ${TP_BASE}
|
||||
const VT_BLK = ${VT_BLK}
|
||||
const CTRL = ${CTRL}
|
||||
const QUEUE_DATA = VT_BLK + ${QUEUE_DATA_OFFSET}
|
||||
const QUEUE_HEAD_ADDR = VT_BLK + 8
|
||||
const QUEUE_TAIL_ADDR = VT_BLK + 9
|
||||
const ACTIVE_VT_ADDR = CTRL + ${CTRL_ACTIVE_VT}
|
||||
const COLS = ${TEXT_COLS}, ROWS = ${TEXT_ROWS}
|
||||
const FORE_BASE = ${TP_FORE_BASE}, BACK_BASE = ${TP_BACK_BASE}, TEXT_BASE = ${TP_TEXT_BASE}
|
||||
|
||||
// ── output shims (write into the per-VT text-plane buffer in shared mem) ──
|
||||
// This is a faithful JS port of the GPU's TTY interpreter (GlassTty.acceptChar
|
||||
// + GraphicsAdapter handlers). TVDOS apps drive the screen by printing control
|
||||
// bytes and escape sequences through print(), so the shim must interpret them
|
||||
// exactly as the hardware would: the \\x84<decimal>u "emit char by code" escape
|
||||
// (used by con.prnch), CSI cursor moves / erase / SGR colours, and the ?25
|
||||
// cursor-visibility private sequence.
|
||||
let curX = 0, curY = 0
|
||||
let foreCol = 254
|
||||
let backCol = 255
|
||||
|
||||
// Per-pane cursor visibility lives at VT_BLK+2 (1 = blink on, 0 = hidden).
|
||||
// The compositor pushes the active pane's value into the GPU's blink bit.
|
||||
const CURSOR_VIS_ADDR = VT_BLK + 2
|
||||
sys.poke(CURSOR_VIS_ADDR, 1)
|
||||
|
||||
// SGR 30-37 / 40-47 → default 8-colour palette (matches GraphicsAdapter).
|
||||
const SGR_PAL = [240, 211, 61, 230, 49, 219, 114, 254]
|
||||
|
||||
function writeCursor() {
|
||||
let pos = curY * COLS + curX
|
||||
sys.poke(TP + 0, pos & 0xFF)
|
||||
sys.poke(TP + 1, (pos >> 8) & 0xFF)
|
||||
}
|
||||
function scrollBufUp(n) {
|
||||
if (n < 1) n = 1
|
||||
if (n > ROWS) n = ROWS
|
||||
for (let p of [FORE_BASE, BACK_BASE, TEXT_BASE]) {
|
||||
for (let y = 0; y < ROWS - n; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
sys.poke(TP + p + y * COLS + x, sys.peek(TP + p + (y + n) * COLS + x))
|
||||
}
|
||||
}
|
||||
let clearVal = (p === TEXT_BASE) ? 0 : (p === FORE_BASE ? foreCol : backCol)
|
||||
for (let y = ROWS - n; y < ROWS; y++)
|
||||
for (let x = 0; x < COLS; x++) sys.poke(TP + p + y * COLS + x, clearVal)
|
||||
}
|
||||
}
|
||||
function putCharRaw(x, y, c) {
|
||||
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return
|
||||
let off = y * COLS + x
|
||||
sys.poke(TP + TEXT_BASE + off, c & 0xFF)
|
||||
sys.poke(TP + FORE_BASE + off, foreCol)
|
||||
sys.poke(TP + BACK_BASE + off, backCol)
|
||||
}
|
||||
// Mirror of GraphicsAdapter.setCursorPos: wrap on overflow x, scroll on
|
||||
// overflow y, clamp y above the screen.
|
||||
function setCursorPos(x, y) {
|
||||
let nx = x, ny = y
|
||||
if (nx >= COLS) { nx = 0; ny += 1 }
|
||||
else if (nx < 0) nx = 0
|
||||
if (ny < 0) ny = 0
|
||||
else if (ny >= ROWS) { scrollBufUp(ny - ROWS + 1); ny = ROWS - 1 }
|
||||
curX = nx; curY = ny
|
||||
writeCursor()
|
||||
}
|
||||
|
||||
// ── TTY actions (mirror the GraphicsAdapter overrides) ────────────────────
|
||||
function ttyPrintable(c) { putCharRaw(curX, curY, c); setCursorPos(curX + 1, curY) }
|
||||
function ttyCrlf() {
|
||||
let ny = curY + 1
|
||||
setCursorPos(0, (ny >= ROWS) ? ROWS - 1 : ny)
|
||||
if (ny >= ROWS) scrollBufUp(1)
|
||||
}
|
||||
function ttyBackspace() { let x = curX, y = curY; setCursorPos(x - 1, y); putCharRaw(curX, curY, 0x20) }
|
||||
function ttyTab() { setCursorPos(((curX / 8 | 0) + 1) * 8, curY) }
|
||||
function ttyResetStatus() { foreCol = 253; backCol = 255 }
|
||||
function ttyEmitChar(code) { putCharRaw(curX, curY, code); setCursorPos(curX + 1, curY) }
|
||||
function ttyCursorUp(n) { setCursorPos(curX, curY - n) }
|
||||
function ttyCursorDown(n) { let ny = curY + n; setCursorPos(curX, (ny >= ROWS) ? ROWS - 1 : ny) }
|
||||
function ttyCursorFwd(n) { setCursorPos(curX + n, curY) }
|
||||
function ttyCursorBack(n) { setCursorPos(curX - n, curY) }
|
||||
function ttyCursorNextLine(n) { let ny = curY + n; setCursorPos(0, (ny >= ROWS) ? ROWS - 1 : ny); if (ny >= ROWS) scrollBufUp(ny - ROWS + 1) }
|
||||
function ttyCursorPrevLine(n) { setCursorPos(0, curY - n) }
|
||||
function ttyCursorX(n) { setCursorPos(n, curY) }
|
||||
function ttyCursorXY(row, col) { setCursorPos(col - 1, row - 1) }
|
||||
function ttyEraseInDisp(arg) {
|
||||
if (arg === 2) {
|
||||
for (let i = 0; i < COLS * ROWS; i++) {
|
||||
sys.poke(TP + TEXT_BASE + i, 0)
|
||||
sys.poke(TP + FORE_BASE + i, foreCol)
|
||||
sys.poke(TP + BACK_BASE + i, backCol)
|
||||
}
|
||||
curX = 0; curY = 0; writeCursor()
|
||||
}
|
||||
// other args: GraphicsAdapter TODOs (throws); we no-op for safety
|
||||
}
|
||||
function ttySgr1(arg) {
|
||||
if (arg >= 30 && arg <= 37) foreCol = SGR_PAL[arg - 30]
|
||||
else if (arg >= 40 && arg <= 47) backCol = SGR_PAL[arg - 40]
|
||||
else if (arg === 7) { let t = foreCol; foreCol = backCol; backCol = t }
|
||||
else if (arg === 0) { foreCol = 253; backCol = 255; sys.poke(CURSOR_VIS_ADDR, 1) }
|
||||
}
|
||||
function ttySgr3(a1, a2, a3) {
|
||||
if (a1 === 38 && a2 === 5) foreCol = a3
|
||||
else if (a1 === 48 && a2 === 5) backCol = a3
|
||||
}
|
||||
function ttyPrivH(arg) { if (arg === 25) sys.poke(CURSOR_VIS_ADDR, 1) }
|
||||
function ttyPrivL(arg) { if (arg === 25) sys.poke(CURSOR_VIS_ADDR, 0) }
|
||||
|
||||
// ── escape-sequence state machine (mirror of GlassTty.acceptChar) ─────────
|
||||
// States: 0 INITIAL, 1 ESC, 2 CSI, 3 NUM1, 4 SEP1, 5 NUM2, 6 SEP2, 7 NUM3,
|
||||
// 8 PRIVATESEQ, 9 PRIVATENUM, 10 XCSI, 11 XNUM1
|
||||
let escState = 0
|
||||
let escArgs = []
|
||||
function isDig(c) { return c >= 0x30 && c <= 0x39 }
|
||||
function escReset() { escState = 0; escArgs.length = 0 }
|
||||
// reject() in hardware returns the char as printable; replicate by printing it
|
||||
function escRejectPrint(c) { escReset(); ttyPrintable(c) }
|
||||
|
||||
function processByte(c) {
|
||||
switch (escState) {
|
||||
case 0: // INITIAL
|
||||
if (c === 0x1B) escState = 1
|
||||
else if (c === 0x84) escState = 10
|
||||
else if (c === 0x0A) ttyCrlf()
|
||||
else if (c === 0x08) ttyBackspace()
|
||||
else if (c === 0x09) ttyTab()
|
||||
else if (c === 0x07) { /* bell */ }
|
||||
else if (c >= 0x00 && c <= 0x1F) { /* other control: ignored */ }
|
||||
else ttyPrintable(c)
|
||||
break
|
||||
case 1: // ESC
|
||||
if (c === 0x63) { ttyResetStatus(); escReset() } // 'c'
|
||||
else if (c === 0x5B) escState = 2 // '['
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
case 2: // CSI
|
||||
if (c === 0x41) { ttyCursorUp(1); escReset() }
|
||||
else if (c === 0x42) { ttyCursorDown(1); escReset() }
|
||||
else if (c === 0x43) { ttyCursorFwd(1); escReset() }
|
||||
else if (c === 0x44) { ttyCursorBack(1); escReset() }
|
||||
else if (c === 0x45) { ttyCursorNextLine(1); escReset() }
|
||||
else if (c === 0x46) { ttyCursorPrevLine(1); escReset() }
|
||||
else if (c === 0x47) { ttyCursorX(1); escReset() }
|
||||
else if (c === 0x4A) { ttyEraseInDisp(0); escReset() }
|
||||
else if (c === 0x4B) { escReset() } // eraseInLine: no-op
|
||||
else if (c === 0x53) { scrollBufUp(1); escReset() } // S
|
||||
else if (c === 0x54) { escReset() } // T scrollDown: no-op
|
||||
else if (c === 0x6D) { ttySgr1(0); escReset() } // m
|
||||
else if (c === 0x3F) escState = 8 // '?'
|
||||
else if (c === 0x3B) { escArgs.push(0); escState = 4 } // ';'
|
||||
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 3 }
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
case 3: // NUM1
|
||||
if (c === 0x41) { ttyCursorUp(escArgs.pop()); escReset() }
|
||||
else if (c === 0x42) { ttyCursorDown(escArgs.pop()); escReset() }
|
||||
else if (c === 0x43) { ttyCursorFwd(escArgs.pop()); escReset() }
|
||||
else if (c === 0x44) { ttyCursorBack(escArgs.pop()); escReset() }
|
||||
else if (c === 0x45) { ttyCursorNextLine(escArgs.pop()); escReset() }
|
||||
else if (c === 0x46) { ttyCursorPrevLine(escArgs.pop()); escReset() }
|
||||
else if (c === 0x47) { ttyCursorX(escArgs.pop()); escReset() }
|
||||
else if (c === 0x4A) { ttyEraseInDisp(escArgs.pop()); escReset() }
|
||||
else if (c === 0x4B) { escArgs.pop(); escReset() }
|
||||
else if (c === 0x53) { scrollBufUp(escArgs.pop()); escReset() }
|
||||
else if (c === 0x54) { escArgs.pop(); escReset() }
|
||||
else if (c === 0x6D) { ttySgr1(escArgs.pop()); escReset() }
|
||||
else if (c === 0x3B) escState = 4
|
||||
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
case 4: // SEP1 (seen "n;")
|
||||
if (isDig(c)) { escArgs.push(c - 0x30); escState = 5 }
|
||||
else if (c === 0x48) { let a1 = escArgs.pop(); ttyCursorXY(a1, 0); escReset() } // H
|
||||
else if (c === 0x6D) { ttySgr1(escArgs.pop()); escReset() } // m (2-arg unimpl in HW)
|
||||
else if (c === 0x3B) { escArgs.push(0); escState = 6 }
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
case 5: // NUM2 (seen "n;n")
|
||||
if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||
else if (c === 0x48) { let a2 = escArgs.pop(), a1 = escArgs.pop(); ttyCursorXY(a1, a2); escReset() }
|
||||
else if (c === 0x6D) { escArgs.pop(); escArgs.pop(); escReset() } // 2-arg SGR unimpl in HW
|
||||
else if (c === 0x3B) escState = 6
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
case 6: // SEP2 (seen "n;n;")
|
||||
if (c === 0x6D) { let a2 = escArgs.pop(), a1 = escArgs.pop(); ttySgr3(a1, a2, 0); escReset() }
|
||||
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 7 }
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
case 7: // NUM3 (seen "n;n;n")
|
||||
if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||
else if (c === 0x6D) { let a3 = escArgs.pop(), a2 = escArgs.pop(), a1 = escArgs.pop(); ttySgr3(a1, a2, a3); escReset() }
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
case 8: // PRIVATESEQ (seen "?")
|
||||
if (isDig(c)) { escArgs.push(c - 0x30); escState = 9 }
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
case 9: // PRIVATENUM (seen "?n")
|
||||
if (c === 0x68) { ttyPrivH(escArgs.pop()); escReset() } // h
|
||||
else if (c === 0x6C) { ttyPrivL(escArgs.pop()); escReset() } // l
|
||||
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
case 10: // XCSI (seen \\x84)
|
||||
if (c === 0x75) { ttyEmitChar(0); escReset() } // 'u'
|
||||
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 11 }
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
case 11: // XNUM1 (seen \\x84<digits>)
|
||||
if (c === 0x75) { ttyEmitChar(escArgs.pop()); escReset() } // 'u'
|
||||
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||
else escRejectPrint(c)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
print = function(s) {
|
||||
if (s === undefined || s === null) return
|
||||
let str = '' + s
|
||||
for (let i = 0; i < str.length; i++) processByte(str.charCodeAt(i))
|
||||
}
|
||||
println = function(s) {
|
||||
if (s === undefined) print("\\n")
|
||||
else print(s + "\\n")
|
||||
}
|
||||
printerr = function(s) { print(s) }
|
||||
printerrln = function(s) { println(s) }
|
||||
|
||||
// command.js's shell.execute reassigns the global print/println/printerr/
|
||||
// printerrln to shell.stdio.out.* (which call sys.print → physical GPU,
|
||||
// bypassing these shims). Expose the buffer writers through a global hook so
|
||||
// shell.stdio.out can delegate to them when running inside a VT pane. The
|
||||
// non-VT path in command.js stays unchanged (hook is undefined there).
|
||||
globalThis.__VT_OUT = { print: print, println: println, printerr: printerr, printerrln: printerrln }
|
||||
|
||||
// con.move / con.getyx are 1-based in TVDOS (graphics.setCursorYX does cx-1,
|
||||
// getCursorYX returns cx+1). Internal curX/curY are 0-based, so convert.
|
||||
con.move = function(y, x) {
|
||||
curY = Math.max(0, Math.min(ROWS - 1, (y | 0) - 1))
|
||||
curX = Math.max(0, Math.min(COLS - 1, (x | 0) - 1))
|
||||
writeCursor()
|
||||
}
|
||||
con.getyx = function() { return [curY + 1, curX + 1] }
|
||||
con.getmaxyx = function() { return [ROWS, COLS] }
|
||||
con.color_pair = function(f, b) { foreCol = f & 0xFF; backCol = b & 0xFF }
|
||||
con.color_fore = function(n) { foreCol = n & 0xFF }
|
||||
con.color_back = function(n) { backCol = n & 0xFF }
|
||||
con.get_color_fore = function() { return foreCol }
|
||||
con.get_color_back = function() { return backCol }
|
||||
// addch writes a glyph at the cursor WITHOUT advancing — matching
|
||||
// graphics.putSymbol(). TVDOS code pairs addch with explicit curs_right();
|
||||
// advancing here would double-step and leave gaps (e.g. the fancy prompt).
|
||||
con.addch = function(c) { putCharRaw(curX, curY, c) }
|
||||
con.mvaddch = function(y, x, c) { con.move(y, x); con.addch(c) }
|
||||
con.curs_up = function(n) { n = n || 1; curY = Math.max(0, curY - n); writeCursor() }
|
||||
con.curs_down = function(n) { n = n || 1; curY = Math.min(ROWS - 1, curY + n); writeCursor() }
|
||||
con.curs_left = function(n) { n = n || 1; curX = Math.max(0, curX - n); writeCursor() }
|
||||
con.curs_right = function(n) { n = n || 1; curX = Math.min(COLS - 1, curX + n); writeCursor() }
|
||||
con.curs_set = function(arg) { sys.poke(CURSOR_VIS_ADDR, ((arg | 0) === 0) ? 0 : 1) }
|
||||
con.video_reverse = function() { /* unsupported; ANSI swallowed */ }
|
||||
con.reset_graphics = function() { foreCol = 254; backCol = 255 }
|
||||
con.clear = function() {
|
||||
for (let i = 0; i < COLS * ROWS; i++) {
|
||||
sys.poke(TP + TEXT_BASE + i, 0)
|
||||
sys.poke(TP + FORE_BASE + i, foreCol)
|
||||
sys.poke(TP + BACK_BASE + i, backCol)
|
||||
}
|
||||
curX = 0; curY = 0; writeCursor()
|
||||
}
|
||||
// prnch prints a glyph and DOES advance (unlike addch) — the real impl emits
|
||||
// it through print() as \\x84<code>u, so route it through the interpreter.
|
||||
con.prnch = function(c) {
|
||||
if (Array.isArray(c)) c.forEach(x => ttyEmitChar(x))
|
||||
else ttyEmitChar(c)
|
||||
}
|
||||
|
||||
// ── input shims ──────────────────────────────────────────────────────────
|
||||
// Pane reads from its own ring buffer in shared mem. NEVER touches physical
|
||||
// keyboard MMIO — that's the dispatcher's exclusive territory. Cooperative
|
||||
// gate on active_vt keeps background panes parked when they call getch.
|
||||
|
||||
function queuePop() {
|
||||
let head = sys.peek(QUEUE_HEAD_ADDR)
|
||||
let tail = sys.peek(QUEUE_TAIL_ADDR)
|
||||
if (head === tail) return -1
|
||||
let b = sys.peek(QUEUE_DATA + head)
|
||||
sys.poke(QUEUE_HEAD_ADDR, (head + 1) & 0xFF)
|
||||
return b
|
||||
}
|
||||
con.getch = function() {
|
||||
while (true) {
|
||||
if (sys.peek(ACTIVE_VT_ADDR) === VT_NUM) {
|
||||
let k = queuePop()
|
||||
if (k >= 0) return k
|
||||
}
|
||||
sys.sleep(20)
|
||||
}
|
||||
}
|
||||
con.hitterminate = function() { return false }
|
||||
con.hiteof = function() { return false }
|
||||
con.resetkeybuf = function() { sys.poke(QUEUE_HEAD_ADDR, sys.peek(QUEUE_TAIL_ADDR)) }
|
||||
con.poll_keys = function() { return [0,0,0,0,0,0,0,0] }
|
||||
|
||||
// ── TVDOS.SYS init flags + BIOS stub ───────────────────────────────────────
|
||||
globalThis._TVDOS_IS_VT_PANE = true
|
||||
globalThis._BIOS = { FIRST_BOOTABLE_PORT: ${BIOS_FIRST_BOOTABLE_PORT} }
|
||||
|
||||
// ── load TVDOS.SYS and run AUTOEXEC.BAT (the per-console shell) in one direct-eval ─────
|
||||
// Strict-mode direct eval is scope-isolated, so TVDOS.SYS's \`const _TVDOS\`
|
||||
// only survives within the eval scope. The shell launcher must run inside
|
||||
// the same eval to access it (via lexical closure into nested evals).
|
||||
eval(${JSON.stringify(combined)})
|
||||
`
|
||||
// The outer execApp's injectIntChk rewrote the first while/for/do (each
|
||||
// kind) in our literal source to call a per-exec SIGTERM check function.
|
||||
// Some of those rewrites landed inside this template literal — the pane
|
||||
// has no such symbol in scope. Strip them; panes don't need SIGTERM
|
||||
// checks (parallel.kill handles teardown).
|
||||
return raw.replace(/tvdosSIGTERM_[A-Za-z0-9_]+\(\);?/g, '')
|
||||
}
|
||||
|
||||
// ─── pane lifecycle ─────────────────────────────────────────────────────────
|
||||
// Lazy spawn: VT 1 at boot; VT 2-6 the first time the user requests them.
|
||||
// Re-spawn if the previous pane's thread has died (e.g. user typed `exit`).
|
||||
|
||||
const panes = {} // n -> { runner, thread }
|
||||
|
||||
function isPaneAlive(n) {
|
||||
return panes[n] && parallel.isRunning(panes[n].thread)
|
||||
}
|
||||
|
||||
function spawnPane(n) {
|
||||
serial.println("[vtmgr] spawning VT " + n)
|
||||
let runner = parallel.spawnNewContext()
|
||||
let thread = parallel.attachProgram("vt" + n, runner, makePaneBootstrap(n))
|
||||
parallel.launch(thread)
|
||||
panes[n] = { runner: runner, thread: thread }
|
||||
sys.poke(CTRL + CTRL_SPAWNED_BITS, sys.peek(CTRL + CTRL_SPAWNED_BITS) | (1 << (n - 1)))
|
||||
}
|
||||
|
||||
function ensurePane(n) {
|
||||
if (!isPaneAlive(n)) {
|
||||
sys.poke(CTRL + CTRL_SPAWNED_BITS, sys.peek(CTRL + CTRL_SPAWNED_BITS) & ~(1 << (n - 1)))
|
||||
spawnPane(n)
|
||||
}
|
||||
}
|
||||
|
||||
ensurePane(1)
|
||||
sys.poke(CTRL + CTRL_ACTIVE_VT, 1)
|
||||
// VT 1's TVDOS.SYS eval is slow; give it room before we start compositing.
|
||||
sys.sleep(800)
|
||||
|
||||
// ─── compositor / dispatcher loop ───────────────────────────────────────────
|
||||
// 30 Hz: blit active pane → GPU text area; honour switch_request; detect
|
||||
// Alt-N with debounce; drain typed chars into active pane's queue.
|
||||
|
||||
const gpuBase = graphics.getGpuMemBase()
|
||||
const TEXTAREA_BASE_ABS = gpuBase - GPU_TEXTAREA_OFFSET
|
||||
function blitVt(srcAddr) {
|
||||
sys.memcpy(srcAddr, TEXTAREA_BASE_ABS, TEXT_PLANE_SIZE - 2)
|
||||
sys.poke(TEXTAREA_BASE_ABS - (TEXT_PLANE_SIZE - 2), sys.peek(srcAddr + TEXT_PLANE_SIZE - 2))
|
||||
sys.poke(TEXTAREA_BASE_ABS - (TEXT_PLANE_SIZE - 1), sys.peek(srcAddr + TEXT_PLANE_SIZE - 1))
|
||||
}
|
||||
|
||||
// GPU textmode-attribute MMIO byte (offset 6): bit 0 = blinkCursor, bit 1 =
|
||||
// rawMode, bits 4-7 = chrrom. We flip only bit 0 to match the active pane's
|
||||
// cursor visibility. getGpuMemBase() = -1 - 1MB*slot; the peripheral's MMIO
|
||||
// window sits at IOSpace offset 128KB*slot, so MMIO byte k = -1 - (128KB*slot + k).
|
||||
const gpuSlot = (((-gpuBase) - 1) / 1048576) | 0
|
||||
const GPU_MMIO_ATTR = -1 - (131072 * gpuSlot + 6)
|
||||
let lastCursorVis = -1
|
||||
function applyCursorVis(active) {
|
||||
let vis = sys.peek(vtBlockAddr(active) + 2)
|
||||
if (vis === lastCursorVis) return
|
||||
let attr = sys.peek(GPU_MMIO_ATTR)
|
||||
sys.poke(GPU_MMIO_ATTR, vis ? (attr | 1) : (attr & 0xFE))
|
||||
lastCursorVis = vis
|
||||
}
|
||||
|
||||
function queuePush(vtN, byte) {
|
||||
let qBase = vtBlockAddr(vtN)
|
||||
let head = sys.peek(qBase + 8)
|
||||
let tail = sys.peek(qBase + 9)
|
||||
let next = (tail + 1) & 0xFF
|
||||
if (next === head) return false
|
||||
sys.poke(qBase + QUEUE_DATA_OFFSET + tail, byte)
|
||||
sys.poke(qBase + 9, next)
|
||||
return true
|
||||
}
|
||||
|
||||
function switchTo(n) {
|
||||
if (n < 1 || n > MAX_VT) return
|
||||
ensurePane(n)
|
||||
sys.poke(CTRL + CTRL_ACTIVE_VT, n)
|
||||
}
|
||||
|
||||
sys.poke(-39, 1) // enable physical keyboard input collection
|
||||
|
||||
let running = true
|
||||
while (running) {
|
||||
let active = sys.peek(CTRL + CTRL_ACTIVE_VT)
|
||||
if (active < 1 || active > MAX_VT) active = 1
|
||||
blitVt(vtTextPlaneAddr(active))
|
||||
applyCursorVis(active)
|
||||
|
||||
// honour chvt's switch request
|
||||
let req = sys.peek(CTRL + CTRL_SWITCH_REQUEST)
|
||||
if (req >= 1 && req <= MAX_VT) {
|
||||
if (req !== active) {
|
||||
serial.println("[vtmgr] chvt switch -> VT " + req)
|
||||
switchTo(req)
|
||||
}
|
||||
sys.poke(CTRL + CTRL_SWITCH_REQUEST, 0)
|
||||
}
|
||||
|
||||
// Alt-N (and Alt-0 = exit) detection
|
||||
sys.poke(-40, 1)
|
||||
let keys = [sys.peek(-41), sys.peek(-42), sys.peek(-43), sys.peek(-44),
|
||||
sys.peek(-45), sys.peek(-46), sys.peek(-47), sys.peek(-48)]
|
||||
let altHeld = keys.indexOf(57) >= 0 || keys.indexOf(58) >= 0
|
||||
let digit = -1
|
||||
for (let n = 0; n <= MAX_VT; n++) {
|
||||
if (keys.indexOf(7 + n) >= 0) { digit = n; break }
|
||||
}
|
||||
let debounce = sys.peek(CTRL + CTRL_DEBOUNCE_HELD) !== 0
|
||||
|
||||
if (debounce) {
|
||||
if (!altHeld && digit < 0) sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 0)
|
||||
}
|
||||
else if (altHeld && digit === 0) {
|
||||
serial.println("[vtmgr] Alt-0 -> exit")
|
||||
running = false
|
||||
sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 1)
|
||||
sys.poke(-39, 1)
|
||||
}
|
||||
else if (altHeld && digit >= 1) {
|
||||
serial.println("[vtmgr] Alt-" + digit + " -> switching to VT " + digit)
|
||||
switchTo(digit)
|
||||
sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 1)
|
||||
sys.poke(-39, 1) // swallow the digit char so it doesn't leak into the queue
|
||||
}
|
||||
|
||||
if (!running) break
|
||||
|
||||
// drain typed chars into the active pane's queue
|
||||
while (sys.peek(-50) !== 0) {
|
||||
let k = sys.peek(-38)
|
||||
if (k < 0) k += 256
|
||||
queuePush(active, k)
|
||||
}
|
||||
|
||||
sys.sleep(33)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= MAX_VT; n++) if (panes[n]) parallel.kill(panes[n].thread)
|
||||
con.color_pair(254, 255)
|
||||
con.clear()
|
||||
println("vtmgr exited.")
|
||||
return 0
|
||||
972
it2taud.py
972
it2taud.py
File diff suppressed because it is too large
Load Diff
382
mod2taud.py
382
mod2taud.py
@@ -7,9 +7,9 @@ Usage:
|
||||
Limits:
|
||||
- Up to 20 MOD channels (excess disabled; hard error if pattern count
|
||||
× channel count > 4095).
|
||||
- Sample bin is 737280 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- Sample bin is 8 MB (8388608 bytes); if all samples together exceed
|
||||
this, every sample is globally resampled down (with c2spd adjusted)
|
||||
so pitch is preserved.
|
||||
|
||||
Effect support:
|
||||
Full PT effect dispatch per TAUD_NOTE_EFFECTS.md "ProTracker to Taud
|
||||
@@ -24,7 +24,7 @@ Effect support:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import copy
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
@@ -40,7 +40,8 @@ from taud_common import (
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
encode_song_entry,
|
||||
encode_song_entry, compress_blob,
|
||||
build_project_data, detect_subsongs,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,6 +60,9 @@ PT_MEM_TOP = frozenset({0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0xA})
|
||||
# E sub-effects with memory (key is sub-nibble of the E command):
|
||||
PT_MEM_E_SUB = frozenset({0x1, 0x2, 0xA, 0xB})
|
||||
|
||||
GLOBAL_FLAGS_AMIGA_FREQ = 0b01
|
||||
GLOBAL_FLAGS_A500_INTP = 0b1000
|
||||
|
||||
|
||||
# ── Taud constants (mod-specific) ────────────────────────────────────────────
|
||||
|
||||
@@ -179,6 +183,26 @@ def parse_mod(data: bytes):
|
||||
inst = (b0 & 0xF0) | ((b2 >> 4) & 0x0F)
|
||||
effect = b2 & 0x0F
|
||||
arg = b3
|
||||
# MT-style PT-strict cell rewrites (LoaderMOD.cpp:354-365):
|
||||
# PT does not recall arg for portamento up/down (1xx, 2xx) or
|
||||
# volume slide (Axx); the literal arg is read every tick. The
|
||||
# vol-slide nibbles in 5xx/6xx likewise take literal args, with
|
||||
# the recalled state living in the porta/vibrato side. So a
|
||||
# zero-arg cell decays to a no-slide variant: 1/2/A drop to
|
||||
# no-op, 5 collapses to bare tone-porta (3), 6 to bare vibrato
|
||||
# (4). Without this, resolve_pt_recalls would back-fill these
|
||||
# zero args from the cohort memory and produce a continuous
|
||||
# slide where PT plays a single-row swell (canonical bug:
|
||||
# GSLINGER ord 0x03 ch1 — `5 01` on r30/r38 with `5 00` on the
|
||||
# rest, was fading 24→0 in 5 rows instead of stair-stepping
|
||||
# 24→14 across 16 rows).
|
||||
if arg == 0:
|
||||
if effect in (0x1, 0x2, 0xA):
|
||||
effect = 0x0
|
||||
elif effect == 0x5:
|
||||
effect = 0x3
|
||||
elif effect == 0x6:
|
||||
effect = 0x4
|
||||
cell = grid[ch][r]
|
||||
cell.period = period
|
||||
cell.inst = inst
|
||||
@@ -226,7 +250,7 @@ def period_to_taud_note(period: int) -> int:
|
||||
if period <= 0:
|
||||
return NOTE_NOP
|
||||
val = round(TAUD_C4 + 4096.0 * math.log2(PT_REFERENCE_PERIOD / period))
|
||||
return max(1, min(0xFFFD, val))
|
||||
return max(0x20, min(0xFFFF, val))
|
||||
|
||||
|
||||
# ── PT effect → Taud effect ──────────────────────────────────────────────────
|
||||
@@ -266,12 +290,17 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
|
||||
return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
|
||||
|
||||
if cmd == 0x5:
|
||||
# Tone porta + vol slide → Taud L (engine splits internally).
|
||||
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
|
||||
# Tone porta + vol slide → Taud L verbatim. PT's 500 recall is already
|
||||
# collapsed by resolve_pt_recalls; if the source had no prior 5xy the
|
||||
# resolved arg is 0, which Taud's L $0000 then recalls from L's own
|
||||
# private memory. Emitting a real L (rather than the previous
|
||||
# G+vol-col split) preserves the slide on rows that also carry a
|
||||
# vol-column SET (e.g., a Cxx fold) — see TAUD_NOTE_EFFECTS.md §L.
|
||||
return (TOP_L, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0x6:
|
||||
# Vibrato + vol slide → Taud K.
|
||||
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
|
||||
# Vibrato + vol slide → Taud K verbatim (same rationale as 0x5).
|
||||
return (TOP_K, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0x7:
|
||||
hi = (arg >> 4) & 0xF
|
||||
@@ -363,7 +392,7 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
|
||||
if arg == 0:
|
||||
return (TOP_NONE, 0, None, None)
|
||||
return (TOP_A, (arg & 0xFF) << 8, None, None)
|
||||
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
|
||||
return (TOP_T, ((arg - 0x19) & 0xFF) << 8, None, None)
|
||||
|
||||
return (TOP_NONE, 0, None, None)
|
||||
|
||||
@@ -517,8 +546,9 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
||||
le = min(s.loop_end, 65535)
|
||||
loop_mode = 1 if (s.flags & 1) else 0
|
||||
flags_byte = loop_mode & 0x3
|
||||
# Envelope first point is full-scale; per-sample level is carried by
|
||||
# IGV (byte 171) so the envelope must contribute a unit multiplier.
|
||||
# Envelope first point is full-scale; per-trigger initial level is
|
||||
# carried by Default Note Volume (byte 196) so the envelope must
|
||||
# contribute a unit multiplier.
|
||||
env_vol = 63
|
||||
# MOD has no envelopes; vol LOOP word b=1 just so the engine evaluates
|
||||
# the unit envelope, plus P=1 (envelope present) for consistency with
|
||||
@@ -540,14 +570,16 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
||||
struct.pack_into('<H', inst_bin, base + 19, 0)
|
||||
inst_bin[base + 21] = env_vol
|
||||
inst_bin[base + 22] = 0
|
||||
# Instrument Global Volume carries the MOD sample's default volume (0..64 → 0..255).
|
||||
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine
|
||||
# multiplies by IGV instead, so the per-instrument level lives here.
|
||||
inst_bin[base + 171] = min(0xFF, round(min(s.volume, 64) * 255 / 64))
|
||||
# MOD has no continuous instrumentwise volume scaler — its `s.volume`
|
||||
# (0..64) is purely the per-trigger initial value. Byte 171 (IGV)
|
||||
# stays at full and byte 196 (DNV) carries the per-instrument default.
|
||||
# Pre-2026-05-09 layout folded s.volume into IGV — see terranmon §2350.
|
||||
inst_bin[base + 171] = 0xFF # IGV: continuous unity
|
||||
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
|
||||
inst_bin[base + 182] = 0xFF # filter cutoff = off
|
||||
inst_bin[base + 183] = 0xFF # filter resonance = off
|
||||
inst_bin[base + 186] = 1 # NNA: note cut
|
||||
inst_bin[base + 196] = min(0xFF, round(min(s.volume, 64) * 255 / 64)) # DNV
|
||||
|
||||
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
|
||||
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
|
||||
@@ -560,7 +592,7 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
||||
# PT hard-pans channels in LRRL order: 0=L 1=R 2=R 3=L (and tile for >4).
|
||||
def _default_channel_pan(ch_idx: int) -> int:
|
||||
side = (ch_idx % 4)
|
||||
return 16 if side in (0, 3) else 47
|
||||
return 8 if side in (0, 3) else 55
|
||||
|
||||
|
||||
def build_pattern(grid: list, ch_idx: int, default_pan: int,
|
||||
@@ -568,9 +600,9 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
|
||||
"""Build a 512-byte Taud pattern for one MOD channel.
|
||||
|
||||
Volume column: explicit Cxx → SEL_SET; effect-folded vol slide → vol_override;
|
||||
otherwise SEL_FINE/0 (no-op). Per-instrument default volume lives in IGV
|
||||
(byte 171) and is applied by the engine on every fresh trigger — the
|
||||
converter no longer has to emit SEL_SET=Sv to scale notes.
|
||||
otherwise SEL_FINE/0 (no-op). Per-instrument default volume lives in DNV
|
||||
(byte 196) and is consulted by the engine when the trigger row has no V
|
||||
column — the converter doesn't need to emit SEL_SET=Sv on plain triggers.
|
||||
"""
|
||||
out = bytearray(PATTERN_BYTES)
|
||||
rows = grid[ch_idx] if ch_idx < len(grid) else [ModRow()] * MOD_PATTERN_ROWS
|
||||
@@ -671,110 +703,133 @@ def find_initial_bpm_speed(patterns: list, order_list: list) -> tuple:
|
||||
return speed, tempo
|
||||
|
||||
|
||||
def assemble_taud(mod: dict) -> bytes:
|
||||
samples = mod['samples']
|
||||
patterns = mod['patterns']
|
||||
def _per_pattern_bxx_mod(patterns: list, n_channels: int):
|
||||
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||
for `detect_subsongs`. MOD patterns are 64 rows × n_channels; Bxx is
|
||||
raw effect digit 0xB.
|
||||
"""
|
||||
def fn(pat_idx: int):
|
||||
if pat_idx < 0 or pat_idx >= len(patterns):
|
||||
return set(), False
|
||||
grid = patterns[pat_idx]
|
||||
targets = set()
|
||||
last_row_has_b = False
|
||||
for ch in range(min(n_channels, len(grid))):
|
||||
ch_rows = grid[ch]
|
||||
for r in range(min(PATTERN_ROWS, len(ch_rows))):
|
||||
cell = ch_rows[r]
|
||||
if cell.effect == 0xB:
|
||||
targets.add(cell.effect_arg & 0xFF)
|
||||
if r == PATTERN_ROWS - 1:
|
||||
last_row_has_b = True
|
||||
return targets, last_row_has_b
|
||||
return fn
|
||||
|
||||
|
||||
def _build_song_payload_mod(mod: dict, patterns_template: list,
|
||||
positions: list, sample_ratio: dict,
|
||||
inst_vols: dict, n_channels: int,
|
||||
*, song_label: str = 'song') -> tuple:
|
||||
"""Build pattern bin + cue sheet + song-entry kwargs for one MOD subsong.
|
||||
|
||||
`patterns_template` is deep-copied so per-song stateful transforms
|
||||
(recall resolution, late-note-delay relocation, Bxx remap) don't leak
|
||||
into the next subsong.
|
||||
"""
|
||||
patterns = copy.deepcopy(patterns_template)
|
||||
order_list = mod['order_list']
|
||||
n_channels = mod['n_channels']
|
||||
virtual_orders = [order_list[pos] for pos in positions]
|
||||
|
||||
vprint(f" [{song_label}] resolving PT per-effect recalls…")
|
||||
resolve_pt_recalls(patterns, virtual_orders, n_channels)
|
||||
|
||||
init_speed, _ = find_initial_bpm_speed(patterns, virtual_orders)
|
||||
relocate_late_note_delays(patterns, virtual_orders, n_channels, init_speed)
|
||||
|
||||
speed, tempo = find_initial_bpm_speed(patterns, virtual_orders)
|
||||
tempo = max(25, min(280, tempo))
|
||||
bpm_stored = (tempo - 25) & 0xFF
|
||||
vprint(f" [{song_label}] initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
n_patterns = mod['n_patterns']
|
||||
|
||||
if n_channels > NUM_VOICES:
|
||||
vprint(f" warning: MOD has {n_channels} channels; truncating to {NUM_VOICES}")
|
||||
n_channels = NUM_VOICES
|
||||
# Cue list and pos→cue mapping, skipping orders that aren't valid pattern refs.
|
||||
cue_list = []
|
||||
pos_to_cue = {}
|
||||
for pos in positions:
|
||||
order = order_list[pos]
|
||||
if order >= n_patterns:
|
||||
continue
|
||||
pos_to_cue[pos] = len(cue_list)
|
||||
cue_list.append(order)
|
||||
|
||||
if n_patterns * n_channels > NUM_PATTERNS_MAX:
|
||||
sys.exit(
|
||||
f"error: {n_patterns} MOD patterns × {n_channels} channels = "
|
||||
f"{n_patterns*n_channels} > {NUM_PATTERNS_MAX} Taud pattern limit.\n"
|
||||
f" Reduce the MOD to ≤ {NUM_PATTERNS_MAX // max(n_channels,1)} patterns."
|
||||
)
|
||||
# Densely renumber the patterns this song uses.
|
||||
used_ordered = []
|
||||
seen = set()
|
||||
for src_pat in cue_list:
|
||||
if src_pat not in seen:
|
||||
used_ordered.append(src_pat)
|
||||
seen.add(src_pat)
|
||||
pat_idx_remap = {src: i for i, src in enumerate(used_ordered)}
|
||||
P_used = len(used_ordered)
|
||||
|
||||
vprint(f" channels: {n_channels}, mod patterns: {n_patterns}, "
|
||||
f"taud patterns: {n_patterns * n_channels}")
|
||||
if P_used * n_channels > NUM_PATTERNS_MAX:
|
||||
sys.exit(f"error: [{song_label}] {P_used} patterns × {n_channels} channels = "
|
||||
f"{P_used*n_channels} > {NUM_PATTERNS_MAX} Taud pattern limit.")
|
||||
|
||||
# Fold Cxx into row.vol_set so the volume column carries explicit set-volume.
|
||||
# This is done in-place before recall resolution so Cxx with arg 0 still
|
||||
# resolves to vol 0 (silence) rather than recalling another effect's memory.
|
||||
for grid in patterns:
|
||||
# Bxx remap on the patterns this song actually emits.
|
||||
crossings = 0
|
||||
for src_pat in used_ordered:
|
||||
if src_pat >= len(patterns): continue
|
||||
grid = patterns[src_pat]
|
||||
for ch in range(min(n_channels, len(grid))):
|
||||
for row in grid[ch]:
|
||||
if row.effect == 0xC:
|
||||
row.vol_set = min(row.effect_arg, 0x3F)
|
||||
row.effect = 0
|
||||
row.effect_arg = 0
|
||||
if row.effect == 0xB:
|
||||
if row.effect_arg in pos_to_cue:
|
||||
row.effect_arg = pos_to_cue[row.effect_arg] & 0xFF
|
||||
else:
|
||||
crossings += 1
|
||||
row.effect_arg = 0
|
||||
if crossings:
|
||||
vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross "
|
||||
f"subsong boundary; clamped to cue 0")
|
||||
|
||||
vprint(" resolving PT per-effect recalls…")
|
||||
resolve_pt_recalls(patterns, order_list, n_channels)
|
||||
|
||||
init_speed, _ = find_initial_bpm_speed(patterns, order_list)
|
||||
relocate_late_note_delays(patterns, order_list, n_channels, init_speed)
|
||||
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
speed, tempo = find_initial_bpm_speed(patterns, order_list)
|
||||
tempo = max(24, min(280, tempo))
|
||||
bpm_stored = (tempo - 24) & 0xFF
|
||||
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
b'\x00\x00\x00\x00' +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
vprint(" building pattern bin…")
|
||||
inst_vols = {
|
||||
i + 1: min(s.volume, 0x3F)
|
||||
for i, s in enumerate(samples)
|
||||
if s.sample_data
|
||||
}
|
||||
pat_bin = bytearray()
|
||||
for pi in range(n_patterns):
|
||||
grid = patterns[pi]
|
||||
for src_pat in used_ordered:
|
||||
grid = patterns[src_pat]
|
||||
for ch in range(n_channels):
|
||||
default_pan = _default_channel_pan(ch)
|
||||
pat_bin += build_pattern(grid, ch, default_pan, inst_vols)
|
||||
assert len(pat_bin) == n_patterns * n_channels * PATTERN_BYTES
|
||||
|
||||
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
|
||||
vprint(" deduplicating patterns…")
|
||||
orig_count = n_patterns * n_channels
|
||||
orig_count = P_used * n_channels
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
||||
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
vprint(" building cue sheet…")
|
||||
cue_sheet = build_cue_sheet(order_list, n_patterns, n_channels, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
|
||||
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
last_active = -1
|
||||
for cue_idx, src_pat in enumerate(cue_list):
|
||||
if cue_idx >= NUM_CUES: break
|
||||
new_pat_idx = pat_idx_remap[src_pat]
|
||||
orig_pats = [new_pat_idx * n_channels + v for v in range(n_channels)]
|
||||
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(
|
||||
[pat_remap[p] for p in orig_pats], 0)
|
||||
last_active = cue_idx
|
||||
|
||||
# ProTracker is Amiga-period-based by definition, so we set the f bit so
|
||||
# the engine applies coarse pitch slides in period space (recovers PT's
|
||||
# characteristic non-linear pitch character).
|
||||
# bit 2 reserved (was 'm' fadeout-zero policy; removed). PT has no instrument-level
|
||||
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire
|
||||
# on sample-end or pattern note-cut instead, which matches PT semantics.
|
||||
flags_byte = 0x02
|
||||
song_table = encode_song_entry(
|
||||
song_offset=song_offset,
|
||||
if last_active >= 0:
|
||||
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||
else:
|
||||
sheet[30] = 0x01
|
||||
|
||||
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||
|
||||
flags_byte = GLOBAL_FLAGS_AMIGA_FREQ | GLOBAL_FLAGS_A500_INTP
|
||||
entry_kwargs = dict(
|
||||
num_voices=n_channels,
|
||||
num_patterns=num_taud_pats,
|
||||
bpm_stored=bpm_stored,
|
||||
@@ -787,9 +842,117 @@ def assemble_taud(mod: dict) -> bytes:
|
||||
global_vol=0xFF,
|
||||
mixing_vol=180,
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
return pat_comp, cue_comp, entry_kwargs
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
|
||||
def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes:
|
||||
samples = mod['samples']
|
||||
patterns = mod['patterns']
|
||||
order_list = mod['order_list']
|
||||
n_channels = mod['n_channels']
|
||||
n_patterns = mod['n_patterns']
|
||||
|
||||
if n_channels > NUM_VOICES:
|
||||
vprint(f" warning: MOD has {n_channels} channels; truncating to {NUM_VOICES}")
|
||||
n_channels = NUM_VOICES
|
||||
vprint(f" channels: {n_channels}, mod patterns: {n_patterns}")
|
||||
|
||||
# Fold Cxx into row.vol_set so the volume column carries explicit set-volume.
|
||||
# This is non-stateful (doesn't depend on order list) so it runs once on the
|
||||
# shared template; per-song deepcopies inherit the folded form.
|
||||
for grid in patterns:
|
||||
for ch in range(min(n_channels, len(grid))):
|
||||
for row in grid[ch]:
|
||||
if row.effect == 0xC:
|
||||
row.vol_set = min(row.effect_arg, 0x3F)
|
||||
row.effect = 0
|
||||
row.effect_arg = 0
|
||||
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
|
||||
inst_vols = {
|
||||
i + 1: min(s.volume, 0x3F)
|
||||
for i, s in enumerate(samples)
|
||||
if s.sample_data
|
||||
}
|
||||
|
||||
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||
# MOD shares IT/S3M's 0xFF-end / 0xFE-skip convention; orders ≥ n_patterns
|
||||
# are also unplayable and treated as skips by the player (build_cue_sheet).
|
||||
skip_set = set([0xFE]) | set(range(n_patterns, 256))
|
||||
subsongs = detect_subsongs(order_list,
|
||||
_per_pattern_bxx_mod(patterns, n_channels),
|
||||
terminators=(0xFF,),
|
||||
skip_marker=skip_set)
|
||||
if not subsongs:
|
||||
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||
subsongs = [{'entry': 0, 'positions': []}]
|
||||
n_songs = len(subsongs)
|
||||
if n_songs == 1:
|
||||
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||
else:
|
||||
vprint(f" detected {n_songs} subsongs:")
|
||||
for i, ss in enumerate(subsongs):
|
||||
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||
|
||||
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||
song_payloads = []
|
||||
for i, ss in enumerate(subsongs):
|
||||
label = f"song {i}" if n_songs > 1 else "song"
|
||||
song_payloads.append(_build_song_payload_mod(
|
||||
mod, patterns, ss['positions'], sample_ratio, inst_vols,
|
||||
n_channels, song_label=label))
|
||||
|
||||
# ── Layout offsets and song table ────────────────────────────────────────
|
||||
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||
|
||||
song_table = bytearray()
|
||||
cur_off = first_song_off
|
||||
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||
entry = encode_song_entry(song_offset=cur_off, **entry_kwargs)
|
||||
assert len(entry) == TAUD_SONG_ENTRY
|
||||
song_table += entry
|
||||
cur_off += len(pat_comp) + len(cue_comp)
|
||||
|
||||
# Project Data (optional). MOD samples *are* its instruments — the names
|
||||
# populate both INam and SNam (1-based; slot 0 empty).
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
names = [''] + [s.name for s in samples[:255]]
|
||||
proj_data = build_project_data(
|
||||
project_name=mod['title'],
|
||||
instrument_names=names,
|
||||
sample_names=names,
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = cur_off
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, n_songs & 0xFF]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
struct.pack('<I', proj_off) +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
out = bytearray()
|
||||
out += header
|
||||
out += compressed
|
||||
out += song_table
|
||||
for pat_comp, cue_comp, _ in song_payloads:
|
||||
out += pat_comp
|
||||
out += cue_comp
|
||||
out += proj_data
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
@@ -801,6 +964,9 @@ def main():
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
|
||||
set_verbose(args.verbose)
|
||||
@@ -815,7 +981,7 @@ def main():
|
||||
vprint(f" orders={len(mod['order_list'])}, patterns={mod['n_patterns']}, "
|
||||
f"samples={sum(1 for s in mod['samples'] if s.sample_data)}")
|
||||
|
||||
taud = assemble_taud(mod)
|
||||
taud = assemble_taud(mod, with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
303
mon2taud.py
303
mon2taud.py
@@ -14,14 +14,15 @@ This converter:
|
||||
- splits each Monotone pattern (64 × N voices) into N Taud patterns
|
||||
- converts notes (A0=27.5 Hz chromatic) to Taud 4096-TET centred on C4
|
||||
- maps the 8 Monotone effects to their closest Taud equivalents
|
||||
- approximates Hz/tick slides (1xx/2xx/3xx) at an A4=440 Hz reference
|
||||
- emits Hz/tick slides (1xx/2xx/3xx) verbatim and turns on Taud's
|
||||
linear-frequency tone mode (Effect 1 ff=2) so the engine interprets
|
||||
E/F/G arguments as Hz at A4=440 Hz reference — no scaling drift
|
||||
|
||||
Limits: numVoices ≤ 20, numPatterns × numVoices ≤ 4095.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import math
|
||||
import copy
|
||||
import struct
|
||||
import sys
|
||||
|
||||
@@ -34,7 +35,8 @@ from taud_common import (
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_E, TOP_F, TOP_G, TOP_H, TOP_J,
|
||||
SEL_SET, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
encode_cue, deduplicate_patterns, encode_song_entry,
|
||||
encode_cue, deduplicate_patterns, encode_song_entry, compress_blob,
|
||||
build_project_data, detect_subsongs,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,11 +53,14 @@ MON_EFFECT_LETTERS = ['0', '1', '2', '3', '4', 'B', 'D', 'F']
|
||||
# Note value 1 = A0; C4 sits at value 40 (A0 + 39 semitones).
|
||||
MON_NOTE_C4 = 40
|
||||
|
||||
# Slides are linear-in-Hz on Monotone but linear-in-4096-TET on Taud. Take A4
|
||||
# (440 Hz) as the reference: 1 Hz at A4 ≈ 12/(440·ln 2) semitones, scaled by
|
||||
# 4096/12 to Taud units. ≈ 161.0. Off by ±1 octave at the extremes; documented
|
||||
# in the script header.
|
||||
SLIDE_UNITS_PER_HZ = 12.0 / (440.0 * math.log(2.0)) * 4096.0 / 12.0
|
||||
# Global behaviour flags byte (Taud Effect 1 / song-table byte 15):
|
||||
# bits 0-1 (ff): tone mode — 2 = linear-frequency (Hz/tick)
|
||||
# Selecting ff=2 makes the engine interpret 1xx/2xx/3xx slide arguments in
|
||||
# audible Hz at the A4=440 Hz reference, matching Monotone's MT_PLAY.PAS
|
||||
# `Frequency:=Frequency±parm1` arithmetic (see MTSRC/MT_PLAY.PAS:606-630).
|
||||
# Panning law is fixed to the equal-energy — there is no `p` bit any more.
|
||||
GLOBAL_FLAGS_LINEAR_FREQ = 0b10
|
||||
GLOBAL_FLAGS_NO_INTERPOLATION = 0b0100
|
||||
|
||||
|
||||
# ── Taud container ───────────────────────────────────────────────────────────
|
||||
@@ -132,9 +137,9 @@ def mon_note_to_taud(mon_note: int) -> int:
|
||||
if mon_note == 0:
|
||||
return NOTE_NOP
|
||||
if mon_note == 0x7F:
|
||||
return NOTE_KEYOFF
|
||||
return NOTE_CUT
|
||||
val = TAUD_C4 + round((mon_note - MON_NOTE_C4) * 4096.0 / 12.0)
|
||||
return max(1, min(0xFFFD, val))
|
||||
return max(0x20, min(0xFFFF, val))
|
||||
|
||||
|
||||
# ── Effect mapping (Monotone 3-bit code + 6-bit data → Taud) ─────────────────
|
||||
@@ -150,17 +155,14 @@ def encode_effect(eff_code: int, data: int) -> tuple:
|
||||
y = data & 0x7
|
||||
return (TOP_J, (J_SEMI_TABLE[x] << 8) | J_SEMI_TABLE[y])
|
||||
|
||||
if letter == '1': # slide up Hz/tick → Taud F
|
||||
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
|
||||
return (TOP_F, arg)
|
||||
if letter == '1': # slide up Hz/tick → Taud F (Hz/tick under ff=2)
|
||||
return (TOP_F, data & 0xFFFF)
|
||||
|
||||
if letter == '2': # slide down Hz/tick → Taud E
|
||||
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
|
||||
return (TOP_E, arg)
|
||||
if letter == '2': # slide down Hz/tick → Taud E (Hz/tick under ff=2)
|
||||
return (TOP_E, data & 0xFFFF)
|
||||
|
||||
if letter == '3': # tone porta Hz/tick → Taud G
|
||||
arg = round(data * SLIDE_UNITS_PER_HZ) & 0xFFFF
|
||||
return (TOP_G, arg)
|
||||
if letter == '3': # tone porta Hz/tick → Taud G (Hz/tick under ff=2)
|
||||
return (TOP_G, data & 0xFFFF)
|
||||
|
||||
if letter == '4': # vibrato xy → Taud H
|
||||
x = (data >> 3) & 0x7 # speed (3 bits)
|
||||
@@ -212,11 +214,15 @@ def build_sample_inst_bin() -> bytes:
|
||||
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch-env flags (P=0 → mixer skips)
|
||||
inst_bin[base + 21] = 63 # vol env pt 0 = full
|
||||
inst_bin[base + 22] = 0
|
||||
inst_bin[base + 171] = 0xA0 # IGV
|
||||
inst_bin[base + 171] = 0xA0 # IGV (square-wave headroom)
|
||||
inst_bin[base + 177] = 0x80 # default pan = centre
|
||||
inst_bin[base + 182] = 0xFF # filter cutoff off
|
||||
inst_bin[base + 183] = 0xFF # filter resonance off
|
||||
inst_bin[base + 186] = 0x01 # NNA: cut
|
||||
# Monotone has no per-sample default volume concept (only one synth
|
||||
# voice, no V column overrides). Set DNV to full so triggers seed
|
||||
# noteVolume at 0x3F; the IGV above provides the actual attenuation.
|
||||
inst_bin[base + 196] = 0xFF # DNV: full
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin)
|
||||
|
||||
@@ -299,7 +305,131 @@ def find_initial_speed(patterns: list, order_list: list, num_voices: int) -> int
|
||||
|
||||
# ── Top-level assembly ───────────────────────────────────────────────────────
|
||||
|
||||
def assemble_taud(mon: dict) -> bytes:
|
||||
def _per_pattern_bxx_mon(patterns: list, num_voices: int):
|
||||
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||
for `detect_subsongs`. Monotone effect index 5 is 'B' (position jump);
|
||||
arg is 6 bits (0..63). Patterns are 64 rows × num_voices. `grid[v][r]`.
|
||||
"""
|
||||
def fn(pat_idx: int):
|
||||
if pat_idx < 0 or pat_idx >= len(patterns):
|
||||
return set(), False
|
||||
grid = patterns[pat_idx]
|
||||
targets = set()
|
||||
last_row_has_b = False
|
||||
for v in range(min(num_voices, len(grid))):
|
||||
v_rows = grid[v]
|
||||
for r in range(min(MON_PATTERN_ROWS, len(v_rows))):
|
||||
cell = v_rows[r]
|
||||
if cell.effect == 5:
|
||||
targets.add(cell.effect_arg & 0x3F)
|
||||
if r == MON_PATTERN_ROWS - 1:
|
||||
last_row_has_b = True
|
||||
return targets, last_row_has_b
|
||||
return fn
|
||||
|
||||
|
||||
def _build_song_payload_mon(mon: dict, patterns_template: list,
|
||||
positions: list, num_voices: int,
|
||||
*, song_label: str = 'song') -> tuple:
|
||||
"""Build pattern bin + cue sheet + song-entry kwargs for one Monotone
|
||||
subsong. Mutates a deepcopy of the patterns to remap Bxx targets to
|
||||
per-song cue indices.
|
||||
"""
|
||||
patterns = copy.deepcopy(patterns_template)
|
||||
order_list = mon['order_list']
|
||||
n_patterns = mon['n_patterns']
|
||||
virtual_orders = [order_list[pos] for pos in positions]
|
||||
|
||||
speed = find_initial_speed(patterns, virtual_orders, num_voices)
|
||||
vprint(f" [{song_label}] initial speed (ticks/row): {speed}")
|
||||
|
||||
cue_list = []
|
||||
pos_to_cue = {}
|
||||
for pos in positions:
|
||||
order = order_list[pos]
|
||||
if order >= n_patterns:
|
||||
continue
|
||||
pos_to_cue[pos] = len(cue_list)
|
||||
cue_list.append(order)
|
||||
|
||||
used_ordered = []
|
||||
seen = set()
|
||||
for src_pat in cue_list:
|
||||
if src_pat not in seen:
|
||||
used_ordered.append(src_pat)
|
||||
seen.add(src_pat)
|
||||
pat_idx_remap = {src: i for i, src in enumerate(used_ordered)}
|
||||
P_used = len(used_ordered)
|
||||
|
||||
if P_used * num_voices > NUM_PATTERNS_MAX:
|
||||
sys.exit(f"error: [{song_label}] {P_used} patterns × {num_voices} voices = "
|
||||
f"{P_used*num_voices} > {NUM_PATTERNS_MAX} Taud pattern limit.")
|
||||
|
||||
# Bxx remap: source position → cue index. Cross-song clamps to cue 0.
|
||||
crossings = 0
|
||||
for src_pat in used_ordered:
|
||||
if src_pat >= len(patterns): continue
|
||||
grid = patterns[src_pat]
|
||||
for v in range(min(num_voices, len(grid))):
|
||||
for row in grid[v]:
|
||||
if row.effect == 5:
|
||||
if row.effect_arg in pos_to_cue:
|
||||
row.effect_arg = pos_to_cue[row.effect_arg] & 0x3F
|
||||
else:
|
||||
crossings += 1
|
||||
row.effect_arg = 0
|
||||
if crossings:
|
||||
vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross "
|
||||
f"subsong boundary; clamped to cue 0")
|
||||
|
||||
pat_bin = bytearray()
|
||||
for src_pat in used_ordered:
|
||||
grid = patterns[src_pat]
|
||||
for v in range(num_voices):
|
||||
pat_bin += build_taud_pattern(grid, v)
|
||||
|
||||
orig_count = P_used * num_voices
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c*CUE_SIZE:(c+1)*CUE_SIZE] = encode_cue([], 0)
|
||||
|
||||
last_active = -1
|
||||
for cue_idx, src_pat in enumerate(cue_list):
|
||||
if cue_idx >= NUM_CUES: break
|
||||
new_pat_idx = pat_idx_remap[src_pat]
|
||||
orig_pats = [new_pat_idx * num_voices + v for v in range(num_voices)]
|
||||
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(
|
||||
[pat_remap[p] for p in orig_pats], 0)
|
||||
last_active = cue_idx
|
||||
if last_active >= 0:
|
||||
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||
|
||||
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||
|
||||
flags_byte = GLOBAL_FLAGS_LINEAR_FREQ | GLOBAL_FLAGS_NO_INTERPOLATION
|
||||
bpm_stored = 150 - 25
|
||||
entry_kwargs = dict(
|
||||
num_voices=num_voices,
|
||||
num_patterns=num_taud_pats,
|
||||
bpm_stored=bpm_stored,
|
||||
tick_rate=speed,
|
||||
base_note=0xA000,
|
||||
base_freq=SQUARE_C2SPD,
|
||||
flags_byte=flags_byte,
|
||||
pat_bin_comp_size=len(pat_comp),
|
||||
cue_sheet_comp_size=len(cue_comp),
|
||||
global_vol=0xFF,
|
||||
mixing_vol=round(180 / num_voices),
|
||||
)
|
||||
return pat_comp, cue_comp, entry_kwargs
|
||||
|
||||
|
||||
def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes:
|
||||
num_voices = mon['num_voices']
|
||||
patterns = mon['patterns']
|
||||
order_list = mon['order_list']
|
||||
@@ -308,86 +438,86 @@ def assemble_taud(mon: dict) -> bytes:
|
||||
if num_voices > NUM_VOICES:
|
||||
vprint(f" warning: {num_voices} voices > {NUM_VOICES}; truncating")
|
||||
num_voices = NUM_VOICES
|
||||
|
||||
if n_patterns * num_voices > NUM_PATTERNS_MAX:
|
||||
sys.exit(
|
||||
f"error: {n_patterns} patterns × {num_voices} voices = "
|
||||
f"{n_patterns*num_voices} > {NUM_PATTERNS_MAX} Taud limit"
|
||||
)
|
||||
|
||||
vprint(f" voices: {num_voices}, mon patterns: {n_patterns}, "
|
||||
f"taud patterns: {n_patterns * num_voices}")
|
||||
|
||||
speed = find_initial_speed(patterns, order_list, num_voices)
|
||||
vprint(f" initial speed (ticks/row): {speed}")
|
||||
vprint(f" voices: {num_voices}, mon patterns: {n_patterns}")
|
||||
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw = build_sample_inst_bin()
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
vprint(" building pattern bin…")
|
||||
pat_bin = bytearray()
|
||||
for pi in range(n_patterns):
|
||||
grid = patterns[pi]
|
||||
for v in range(num_voices):
|
||||
pat_bin += build_taud_pattern(grid, v)
|
||||
assert len(pat_bin) == n_patterns * num_voices * PATTERN_BYTES
|
||||
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||
# Monotone strips 0xFF (skip) markers during parse, so the order list is
|
||||
# already a clean sequence of pattern indices. No terminator/skip values
|
||||
# to feed the detector — subsongs only emerge from the Bxx graph.
|
||||
skip_set = set(range(n_patterns, 256)) # invalid pattern refs → skip
|
||||
subsongs = detect_subsongs(order_list,
|
||||
_per_pattern_bxx_mon(patterns, num_voices),
|
||||
terminators=(),
|
||||
skip_marker=skip_set)
|
||||
if not subsongs:
|
||||
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||
subsongs = [{'entry': 0, 'positions': []}]
|
||||
n_songs = len(subsongs)
|
||||
if n_songs == 1:
|
||||
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||
else:
|
||||
vprint(f" detected {n_songs} subsongs:")
|
||||
for i, ss in enumerate(subsongs):
|
||||
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||
|
||||
vprint(" deduplicating patterns…")
|
||||
orig_count = n_patterns * num_voices
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||
song_payloads = []
|
||||
for i, ss in enumerate(subsongs):
|
||||
label = f"song {i}" if n_songs > 1 else "song"
|
||||
song_payloads.append(_build_song_payload_mon(
|
||||
mon, patterns, ss['positions'], num_voices, song_label=label))
|
||||
|
||||
vprint(" building cue sheet…")
|
||||
cue_sheet = build_cue_sheet(order_list, num_voices, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
# ── Layout offsets and song table ────────────────────────────────────────
|
||||
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
song_table = bytearray()
|
||||
cur_off = first_song_off
|
||||
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||
entry = encode_song_entry(song_offset=cur_off, **entry_kwargs)
|
||||
assert len(entry) == TAUD_SONG_ENTRY
|
||||
song_table += entry
|
||||
cur_off += len(pat_comp) + len(cue_comp)
|
||||
|
||||
# Project Data (optional). Monotone has no title, no user instruments and
|
||||
# no per-sample names, but we still emit one identifying entry so the
|
||||
# synthesised square slot is documented.
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
proj_data = build_project_data(
|
||||
instrument_names=['', 'PC speaker square'],
|
||||
sample_names=['', 'PC speaker square'],
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = cur_off
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
# Header: magic, version, num_songs=1, comp_size of sample+inst, projOff=0, sig.
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC
|
||||
+ bytes([TAUD_VERSION, 1])
|
||||
+ bytes([TAUD_VERSION, n_songs & 0xFF])
|
||||
+ struct.pack('<I', comp_size)
|
||||
+ b'\x00\x00\x00\x00'
|
||||
+ struct.pack('<I', proj_off)
|
||||
+ sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
|
||||
# BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone).
|
||||
bpm_stored = 150 - 24
|
||||
# bit 2 reserved (was 'm' fadeout-zero policy; removed). Monotone has no instrument-level
|
||||
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on
|
||||
# sample-end or pattern note-cut instead.
|
||||
flags_byte = 0x00
|
||||
|
||||
song_table = encode_song_entry(
|
||||
song_offset = song_offset,
|
||||
num_voices = num_voices,
|
||||
num_patterns = num_taud_pats,
|
||||
bpm_stored = bpm_stored,
|
||||
tick_rate = speed,
|
||||
base_note = 0xA000,
|
||||
base_freq = SQUARE_C2SPD,
|
||||
flags_byte = flags_byte,
|
||||
pat_bin_comp_size = len(pat_comp),
|
||||
cue_sheet_comp_size = len(cue_comp),
|
||||
global_vol = 0xFF,
|
||||
mixing_vol = round(180 / num_voices),
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
out = bytearray()
|
||||
out += header
|
||||
out += compressed
|
||||
out += song_table
|
||||
for pat_comp, cue_comp, _ in song_payloads:
|
||||
out += pat_comp
|
||||
out += cue_comp
|
||||
out += proj_data
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
@@ -399,6 +529,9 @@ def main():
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
|
||||
set_verbose(args.verbose)
|
||||
@@ -411,7 +544,7 @@ def main():
|
||||
vprint(f" songLen={mon['song_len']}, voices={mon['num_voices']}, "
|
||||
f"patterns={mon['n_patterns']}, orders={len(mon['order_list'])}")
|
||||
|
||||
taud = assemble_taud(mon)
|
||||
taud = assemble_taud(mon, with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
392
s3m2taud.py
392
s3m2taud.py
@@ -7,9 +7,9 @@ Usage:
|
||||
Limits:
|
||||
- Up to 20 S3M channels (excess disabled; hard error if pattern count
|
||||
× channel count > 4095).
|
||||
- Sample bin is 737280 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- Sample bin is 8 MB (8388608 bytes); if all samples together exceed
|
||||
this, every sample is globally resampled down (with c2spd adjusted)
|
||||
so pitch is preserved.
|
||||
- AdLib instruments are skipped.
|
||||
|
||||
Effect support:
|
||||
@@ -25,7 +25,7 @@ Effect support:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import copy
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
@@ -37,14 +37,15 @@ from taud_common import (
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
|
||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
TOP_J, TOP_K, TOP_L, TOP_M, TOP_N, TOP_O, TOP_P, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J,
|
||||
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
||||
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry,
|
||||
normalise_sample, encode_song_entry, compress_blob,
|
||||
build_project_data, detect_subsongs,
|
||||
)
|
||||
|
||||
|
||||
@@ -137,7 +138,11 @@ def parse_instruments(data: bytes, h: S3MHeader) -> list:
|
||||
continue
|
||||
inst = S3MInstrument()
|
||||
inst.itype = data[ptr]
|
||||
inst.filename = data[ptr+1:ptr+13].rstrip(b'\x00').decode('latin-1', errors='replace')
|
||||
# 12-byte DOS filename field; null-terminated with possible trailing
|
||||
# garbage after the terminator (ST3 doesn't zero the tail). Truncate at
|
||||
# the first null. This field carries the per-sample short name (e.g.
|
||||
# 'HIT1') as distinct from the 28-byte title at 0x30.
|
||||
inst.filename = data[ptr+1:ptr+13].split(b'\x00', 1)[0].decode('latin-1', errors='replace')
|
||||
# memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk)
|
||||
memseg_hi = data[ptr + 0x0D]
|
||||
memseg_lo = struct.unpack_from('<H', data, ptr + 0x0E)[0]
|
||||
@@ -226,14 +231,14 @@ def encode_note(s3m_note: int) -> int:
|
||||
if s3m_note == S3M_NOTE_EMPTY:
|
||||
return NOTE_NOP
|
||||
if s3m_note == S3M_NOTE_OFF:
|
||||
return NOTE_KEYOFF
|
||||
return NOTE_CUT
|
||||
octave = (s3m_note >> 4) & 0xF
|
||||
pitch = s3m_note & 0xF
|
||||
if pitch > 11:
|
||||
return NOTE_NOP
|
||||
semitones = (octave - 4) * 12 + pitch
|
||||
val = round(TAUD_C4 + semitones * 4096 / 12)
|
||||
return max(1, min(0xFFFD, val))
|
||||
return max(0x20, min(0xFFFF, val))
|
||||
|
||||
|
||||
def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
@@ -305,25 +310,28 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
None, None)
|
||||
|
||||
if cmd == EFF_K:
|
||||
# K = vibrato continuation + vol slide; engine treats K as no-op.
|
||||
# Split into: H $0000 (recall vibrato from HU memory) + vol-col slide.
|
||||
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
|
||||
# K = vibrato continuation + vol slide; emitted verbatim. ST3's shared
|
||||
# memory cohort is already resolved upstream by resolve_st3_recalls.
|
||||
return (TOP_K, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_L:
|
||||
# L = tone-porta continuation + vol slide; split similarly.
|
||||
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
|
||||
# L = tone-porta continuation + vol slide; emitted verbatim.
|
||||
return (TOP_L, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_M:
|
||||
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
|
||||
# M = set channel volume; literal byte (no recall). Clamp ST3/IT $40 → $3F.
|
||||
return (TOP_M, (min(arg, 0x3F) & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_N:
|
||||
return (TOP_NONE, 0, d_arg_to_col(arg), None)
|
||||
# N = channel volume slide; D-style encoding.
|
||||
return (TOP_N, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_O:
|
||||
return (TOP_O, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_P:
|
||||
return (TOP_NONE, 0, None, d_arg_to_col(arg))
|
||||
# P = channel panning slide; D-style encoding (low nib = right, high nib = left).
|
||||
return (TOP_P, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_Q:
|
||||
return (TOP_Q, (arg & 0xFF) << 8, None, None)
|
||||
@@ -349,7 +357,7 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
|
||||
if cmd == EFF_T:
|
||||
if arg >= 0x20:
|
||||
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
|
||||
return (TOP_T, ((arg - 0x19) & 0xFF) << 8, None, None)
|
||||
# OpenMPT slide forms: $0y down per tick, $1y up per tick.
|
||||
return (TOP_T, arg & 0xFF, None, None)
|
||||
|
||||
@@ -496,8 +504,9 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
loop_mode = 1 if (inst.flags & 1) else 0
|
||||
flags_byte = loop_mode & 0x3 # 0b 0000 00pp
|
||||
|
||||
# Volume envelope first point is full-scale; per-sample level is carried
|
||||
# by IGV (byte 171) so the envelope contributes a unit multiplier.
|
||||
# Volume envelope first point is full-scale; per-trigger initial level
|
||||
# is carried by Default Note Volume (byte 196), so the envelope
|
||||
# contributes a unit multiplier.
|
||||
env_vol = 63
|
||||
# Vol LOOP word: P=1 (envelope present) | b=1 (use envelope) — no actual
|
||||
# loop / sustain. P added 2026-05-06 alongside the pan/pf gate spec
|
||||
@@ -520,14 +529,17 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
# Volume env point 0: hold at env_vol indefinitely (offset minifloat = 0 → hold).
|
||||
inst_bin[base + 21] = env_vol
|
||||
inst_bin[base + 22] = 0
|
||||
# Instrument Global Volume carries the S3M instrument's default volume (0..64 → 0..255).
|
||||
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine
|
||||
# multiplies by IGV instead, so the per-instrument level lives here.
|
||||
inst_bin[base + 171] = min(0xFF, round(min(inst.volume, 64) * 255 / 64))
|
||||
# S3M has no continuous instrumentwise volume scaler — its `inst.volume`
|
||||
# (0..64) is purely the per-trigger initial value, equivalent to IT's
|
||||
# sample.vol. So byte 171 (IGV) stays at full and byte 196 (DNV)
|
||||
# carries the per-instrument default. Pre-2026-05-09 layout folded
|
||||
# inst.volume into IGV — see terranmon §2350.
|
||||
inst_bin[base + 171] = 0xFF # IGV: continuous unity
|
||||
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
|
||||
inst_bin[base + 182] = 0xFF # filter cutoff = off
|
||||
inst_bin[base + 183] = 0xFF # filter resonance = off
|
||||
inst_bin[base + 186] = 1 # NNA: note cut
|
||||
inst_bin[base + 196] = min(0xFF, round(min(inst.volume, 64) * 255 / 64)) # DNV
|
||||
|
||||
vprint(f" instrument[{base // INST_STRIDE}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
|
||||
if inst.c2spd > 65535:
|
||||
@@ -555,8 +567,9 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
|
||||
|
||||
Volume column: explicit S3M cell vol -> SEL_SET; M/N/K/L vol slides folded
|
||||
by encode_effect -> vol_override; otherwise SEL_FINE/0 (no-op). Per-
|
||||
instrument default volume lives in IGV (byte 171) and is applied by the
|
||||
engine on every fresh trigger, so the converter no longer emits SEL_SET=Sv.
|
||||
instrument default volume lives in DNV (byte 196) and is consulted by
|
||||
the engine when the trigger row has no V column, so the converter
|
||||
doesn't need to emit SEL_SET=Sv on plain trigger rows.
|
||||
Pan column: row 0 emits SEL_SET = default_pan to position the channel;
|
||||
other rows default to SEL_FINE/0 unless an X/P/etc effect overrides.
|
||||
"""
|
||||
@@ -716,110 +729,146 @@ def find_initial_bpm_speed(patterns: list, order_list: list,
|
||||
return speed, tempo
|
||||
|
||||
|
||||
def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
# Determine active channels (bit7 clear = enabled)
|
||||
active_channels = [i for i, cs in enumerate(h.channel_settings)
|
||||
if i < 32 and not (cs & 0x80)][:NUM_VOICES]
|
||||
C = len(active_channels)
|
||||
P = len(patterns)
|
||||
def _per_pattern_bxx_s3m(patterns: list):
|
||||
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||
for `detect_subsongs`. `kills_fallthrough` is True iff the pattern carries
|
||||
a Bxx on its absolute last row (the unconditional terminating-jump idiom).
|
||||
S3M patterns are always 64 rows.
|
||||
"""
|
||||
def fn(pat_idx: int):
|
||||
if pat_idx < 0 or pat_idx >= len(patterns):
|
||||
return set(), False
|
||||
grid = patterns[pat_idx]
|
||||
targets = set()
|
||||
last_row_has_b = False
|
||||
for ch in range(min(32, len(grid))):
|
||||
ch_rows = grid[ch]
|
||||
for r in range(min(PATTERN_ROWS, len(ch_rows))):
|
||||
cell = ch_rows[r]
|
||||
if getattr(cell, 'effect', 0) == EFF_B:
|
||||
targets.add(cell.effect_arg)
|
||||
if r == PATTERN_ROWS - 1:
|
||||
last_row_has_b = True
|
||||
return targets, last_row_has_b
|
||||
return fn
|
||||
|
||||
if P * C > NUM_PATTERNS_MAX:
|
||||
|
||||
def _build_song_payload_s3m(h: S3MHeader, patterns_template: list,
|
||||
positions: list, sample_ratio: dict,
|
||||
inst_vols: dict, active_channels: list,
|
||||
*, song_label: str = 'song') -> tuple:
|
||||
"""Build pattern bin + cue sheet + song-entry kwargs for one subsong.
|
||||
|
||||
Returns (pat_comp, cue_comp, entry_kwargs). The caller fills in
|
||||
`song_offset` from the global layout. `patterns_template` is deep-copied
|
||||
so per-song stateful walks (recall resolution, late-note-delay
|
||||
relocation, Bxx remap) don't leak into the next subsong.
|
||||
"""
|
||||
pats = copy.deepcopy(patterns_template)
|
||||
virtual_orders = [h.order_list[pos] for pos in positions]
|
||||
|
||||
vprint(f" [{song_label}] resolving ST3 shared-memory recalls…")
|
||||
resolve_st3_recalls(pats, virtual_orders, 32)
|
||||
warn_st3_quirks(pats, virtual_orders, 32)
|
||||
|
||||
init_speed, _ = find_initial_bpm_speed(pats, virtual_orders,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
relocate_late_note_delays(pats, virtual_orders, 32, init_speed)
|
||||
|
||||
speed, tempo = find_initial_bpm_speed(pats, virtual_orders,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
tempo = max(25, min(280, tempo))
|
||||
bpm_stored = (tempo - 25) & 0xFF
|
||||
vprint(f" [{song_label}] initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
# Cue list (source pattern indices) and pos→cue mapping. Skip orders that
|
||||
# already terminate (S3M_ORDER_END) or point past the pattern table.
|
||||
cue_list = []
|
||||
pos_to_cue = {}
|
||||
for pos in positions:
|
||||
order = h.order_list[pos]
|
||||
if order >= S3M_ORDER_END or order >= len(pats):
|
||||
continue
|
||||
pos_to_cue[pos] = len(cue_list)
|
||||
cue_list.append(order)
|
||||
|
||||
# Densely renumber the patterns this song actually emits.
|
||||
used_ordered = []
|
||||
seen = set()
|
||||
for src_pat in cue_list:
|
||||
if src_pat not in seen:
|
||||
used_ordered.append(src_pat)
|
||||
seen.add(src_pat)
|
||||
pat_idx_remap = {src: i for i, src in enumerate(used_ordered)}
|
||||
P_used = len(used_ordered)
|
||||
|
||||
C = len(active_channels)
|
||||
if P_used * C > NUM_PATTERNS_MAX:
|
||||
sys.exit(
|
||||
f"error: {P} S3M patterns × {C} channels = {P*C} > {NUM_PATTERNS_MAX} Taud pattern limit.\n"
|
||||
f" Reduce the S3M to ≤ {NUM_PATTERNS_MAX // max(C,1)} patterns, or mute "
|
||||
f"channels to bring active count below {NUM_PATTERNS_MAX // max(P,1) + 1}."
|
||||
f"error: [{song_label}] {P_used} patterns × {C} channels = "
|
||||
f"{P_used*C} > {NUM_PATTERNS_MAX} Taud pattern limit."
|
||||
)
|
||||
|
||||
vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}")
|
||||
# Bxx remap: target source-position → cue-index. Cross-subsong jumps
|
||||
# clamp to cue 0 (loop the subsong rather than jump out of bounds). Walk
|
||||
# only the patterns this song actually emits.
|
||||
crossings = 0
|
||||
for src_pat in used_ordered:
|
||||
if src_pat >= len(pats): continue
|
||||
grid = pats[src_pat]
|
||||
for ch in range(min(32, len(grid))):
|
||||
for row in grid[ch]:
|
||||
if row.effect == EFF_B:
|
||||
if row.effect_arg in pos_to_cue:
|
||||
row.effect_arg = pos_to_cue[row.effect_arg] & 0xFF
|
||||
else:
|
||||
crossings += 1
|
||||
row.effect_arg = 0
|
||||
if crossings:
|
||||
vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross "
|
||||
f"subsong boundary; clamped to cue 0")
|
||||
|
||||
# Resolve ST3 shared-memory recalls (D/E/F/I/J/K/L/Q/R/S with $00 arg)
|
||||
# before any per-row encoding, so cohort-aware Taud effects see explicit
|
||||
# arguments. Mutates patterns in place.
|
||||
vprint(" resolving ST3 shared-memory recalls…")
|
||||
resolve_st3_recalls(patterns, h.order_list, 32)
|
||||
warn_st3_quirks(patterns, h.order_list, 32)
|
||||
|
||||
init_speed, _ = find_initial_bpm_speed(patterns, h.order_list,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
relocate_late_note_delays(patterns, h.order_list, 32, init_speed)
|
||||
|
||||
# Build sample+instrument bin
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(instruments)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
# Compress
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
# Initial BPM / speed
|
||||
speed, tempo = find_initial_bpm_speed(patterns, h.order_list,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
tempo = max(24, min(280, tempo))
|
||||
bpm_stored = (tempo - 24) & 0xFF
|
||||
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
# Song offset = header(32) + compressed + song_table(8)
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
num_taud_pats = P * C
|
||||
|
||||
# Header (32 bytes): magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14)
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
b'\x00\x00\x00\x00' +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
# Pattern bin: for each s3m pattern, for each active channel, 512 bytes
|
||||
vprint(" building pattern bin…")
|
||||
default_pans = [_default_channel_pan(h.channel_settings[ch]) for ch in active_channels]
|
||||
# 1-based inst index → default volume (0..63) for note-trigger vol injection.
|
||||
inst_vols = {
|
||||
i + 1: min(inst.volume, 0x3F)
|
||||
for i, inst in enumerate(instruments)
|
||||
if inst is not None and inst.itype == S3M_TYPE_PCM
|
||||
}
|
||||
# Pattern bin: emit only patterns this song uses (densely indexed).
|
||||
default_pans = [_default_channel_pan(h.channel_settings[ch])
|
||||
for ch in active_channels]
|
||||
pat_bin = bytearray()
|
||||
for pi in range(P):
|
||||
grid = patterns[pi]
|
||||
for src_pat in used_ordered:
|
||||
grid = pats[src_pat]
|
||||
for vi, ch in enumerate(active_channels):
|
||||
pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides,
|
||||
inst_vols, amiga_mode=not h.linear_slides)
|
||||
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
|
||||
pat_bin += build_pattern(grid, ch, default_pans[vi],
|
||||
h.linear_slides, inst_vols,
|
||||
amiga_mode=not h.linear_slides)
|
||||
|
||||
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
|
||||
# Deduplicate identical patterns
|
||||
vprint(" deduplicating patterns…")
|
||||
orig_count = num_taud_pats
|
||||
orig_count = P_used * C
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique ({orig_count - num_taud_pats} deduplicated)")
|
||||
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
# Cue sheet (using remapped pattern indices)
|
||||
vprint(" building cue sheet…")
|
||||
cue_sheet = build_cue_sheet(h.order_list, P, C, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
# Cue sheet
|
||||
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
|
||||
|
||||
# Compress pattern bin and cue sheet (per Taud spec)
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
last_active = -1
|
||||
for cue_idx, src_pat in enumerate(cue_list):
|
||||
if cue_idx >= NUM_CUES: break
|
||||
new_pat_idx = pat_idx_remap[src_pat]
|
||||
orig_pats = [new_pat_idx * C + v for v in range(C)]
|
||||
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(
|
||||
[pat_remap[p] for p in orig_pats], 0)
|
||||
last_active = cue_idx
|
||||
|
||||
# Song table row (32 bytes; see encode_song_entry).
|
||||
# flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted).
|
||||
# bit 2 reserved (was 'm' fadeout-zero policy; removed). S3M has no instrument-level
|
||||
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on
|
||||
# sample-end or pattern note-cut effects (SCx) instead, which matches ST3 semantics.
|
||||
flags_byte = (0x00 if h.linear_slides else 0x02)
|
||||
song_table = encode_song_entry(
|
||||
song_offset=song_offset,
|
||||
if last_active >= 0:
|
||||
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||
else:
|
||||
sheet[30] = 0x01
|
||||
|
||||
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||
|
||||
flags_byte = (0x00 if h.linear_slides else 0x01)
|
||||
entry_kwargs = dict(
|
||||
num_voices=C,
|
||||
num_patterns=num_taud_pats,
|
||||
bpm_stored=bpm_stored,
|
||||
@@ -832,9 +881,108 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
global_vol=0xFF,
|
||||
mixing_vol=180,
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
return pat_comp, cue_comp, entry_kwargs
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
|
||||
def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
|
||||
with_project_data: bool = True) -> bytes:
|
||||
# Determine active channels (bit7 clear = enabled)
|
||||
active_channels = [i for i, cs in enumerate(h.channel_settings)
|
||||
if i < 32 and not (cs & 0x80)][:NUM_VOICES]
|
||||
C = len(active_channels)
|
||||
P = len(patterns)
|
||||
vprint(f" channels: {C}, s3m patterns: {P}")
|
||||
|
||||
# Build sample+instrument bin (shared across subsongs)
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(instruments)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
|
||||
# 1-based inst index → default volume (0..63) for note-trigger vol injection.
|
||||
inst_vols = {
|
||||
i + 1: min(inst.volume, 0x3F)
|
||||
for i, inst in enumerate(instruments)
|
||||
if inst is not None and inst.itype == S3M_TYPE_PCM
|
||||
}
|
||||
|
||||
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||
subsongs = detect_subsongs(h.order_list, _per_pattern_bxx_s3m(patterns),
|
||||
terminators=(S3M_ORDER_END,),
|
||||
skip_marker=S3M_ORDER_SKIP)
|
||||
if not subsongs:
|
||||
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||
subsongs = [{'entry': 0, 'positions': []}]
|
||||
n_songs = len(subsongs)
|
||||
if n_songs == 1:
|
||||
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||
else:
|
||||
vprint(f" detected {n_songs} subsongs:")
|
||||
for i, ss in enumerate(subsongs):
|
||||
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||
|
||||
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||
song_payloads = []
|
||||
for i, ss in enumerate(subsongs):
|
||||
label = f"song {i}" if n_songs > 1 else "song"
|
||||
song_payloads.append(_build_song_payload_s3m(
|
||||
h, patterns, ss['positions'], sample_ratio, inst_vols,
|
||||
active_channels, song_label=label))
|
||||
|
||||
# ── Layout offsets and song table ────────────────────────────────────────
|
||||
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||
|
||||
song_table = bytearray()
|
||||
cur_off = first_song_off
|
||||
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||
entry = encode_song_entry(song_offset=cur_off, **entry_kwargs)
|
||||
assert len(entry) == TAUD_SONG_ENTRY
|
||||
song_table += entry
|
||||
cur_off += len(pat_comp) + len(cue_comp)
|
||||
|
||||
# ── Project Data (optional) ──────────────────────────────────────────────
|
||||
# S3M instruments and samples share the same slot space, but carry two
|
||||
# distinct name fields: the 28-byte title (inst.name → INam) and the
|
||||
# 12-byte DOS filename (inst.filename → SNam). e.g. WHEN.s3m instrument #1
|
||||
# is titled "(c) Purple Motion / 1994" with sample name 'HIT1'.
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
inst_names = [''] + [(inst.name if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
sample_names = [''] + [(inst.filename if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
proj_data = build_project_data(
|
||||
project_name=h.title,
|
||||
instrument_names=inst_names,
|
||||
sample_names=sample_names,
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = cur_off
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
# ── Header ───────────────────────────────────────────────────────────────
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, n_songs & 0xFF]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
struct.pack('<I', proj_off) +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
out = bytearray()
|
||||
out += header
|
||||
out += compressed
|
||||
out += song_table
|
||||
for pat_comp, cue_comp, _ in song_payloads:
|
||||
out += pat_comp
|
||||
out += cue_comp
|
||||
out += proj_data
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
@@ -846,6 +994,9 @@ def main():
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
|
||||
set_verbose(args.verbose)
|
||||
@@ -861,7 +1012,8 @@ def main():
|
||||
instruments = parse_instruments(data, h)
|
||||
patterns = parse_patterns(data, h)
|
||||
|
||||
taud = assemble_taud(h, instruments, patterns)
|
||||
taud = assemble_taud(h, instruments, patterns,
|
||||
with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
493
taud_common.py
493
taud_common.py
@@ -7,9 +7,16 @@ pattern deduper, sample normaliser) that all three converters used to
|
||||
duplicate verbatim.
|
||||
"""
|
||||
|
||||
import gzip as _gzip
|
||||
import struct
|
||||
import sys
|
||||
|
||||
try:
|
||||
import zstandard as _zstd
|
||||
_ZSTD_CCTX = _zstd.ZstdCompressor(level=22)
|
||||
except ImportError:
|
||||
_ZSTD_CCTX = None
|
||||
|
||||
|
||||
# ── Verbose logging (shared across converters via set_verbose) ───────────────
|
||||
|
||||
@@ -24,16 +31,57 @@ def vprint(*a, **kw) -> None:
|
||||
print(*a, **kw, file=sys.stderr)
|
||||
|
||||
|
||||
# ── Compression (gzip vs zstd; whichever is smaller) ─────────────────────────
|
||||
#
|
||||
# The Taud loader sniffs the 4-byte magic of every compressed slot and routes
|
||||
# to GZIPInputStream or ZstdInputStream accordingly (CompressorDelegate.kt:148-149),
|
||||
# so each blob can independently pick whichever codec compresses it smaller.
|
||||
|
||||
def best_compress(payload: bytes) -> tuple:
|
||||
"""Return (compressed_bytes, method) for the smaller of gzip/zstd output.
|
||||
|
||||
Method is "gzip" or "zstd". Falls back to gzip when the `zstandard`
|
||||
package is not installed.
|
||||
"""
|
||||
gz = _gzip.compress(payload, compresslevel=9, mtime=0)
|
||||
if _ZSTD_CCTX is None:
|
||||
return gz, "gzip"
|
||||
zs = _ZSTD_CCTX.compress(payload)
|
||||
if len(zs) < len(gz):
|
||||
return zs, "zstd"
|
||||
return gz, "gzip"
|
||||
|
||||
|
||||
def compress_blob(payload: bytes, label: str) -> bytes:
|
||||
"""Compress `payload` with whichever of gzip/zstd is smaller; vprint stats; return bytes.
|
||||
|
||||
`label` is the human-readable name in the verbose log line, e.g. "sample+inst bin".
|
||||
"""
|
||||
out, method = best_compress(payload)
|
||||
vprint(f" {label}: {len(payload)} → {len(out)} bytes ({method})")
|
||||
return out
|
||||
|
||||
|
||||
# ── Taud container constants ─────────────────────────────────────────────────
|
||||
|
||||
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
|
||||
# Bumped 2026-05-07: envelope offset minifloat rebiased (smallest step 1/256 s,
|
||||
# max 15.75 s; previously 1/32 s, max 126 s). v1 .taud envelopes will play with
|
||||
# the wrong tempo on a v2 engine — re-convert from source.
|
||||
TAUD_VERSION = 1
|
||||
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
|
||||
TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry)
|
||||
INST_RECORD_SIZE = 256 # widened 2026-05-06 (was 192). 256 inst × 256 = 64K.
|
||||
SAMPLEBIN_SIZE = 720896 # was 737280; 16K reallocated to inst bin (terranmon.txt:1985-1997)
|
||||
# Sample+instrument image (terranmon.txt:1985-1997, 2533-2564 — updated 2026-05-08).
|
||||
# Sample pool is now 8 MB, banked through MMIO 46 in 16 × 512 K windows.
|
||||
# Converters write the pool bank-major (bank 0's 512 K first, then bank 1's, ...);
|
||||
# the runtime decompresses the whole blob straight into native peripheral storage,
|
||||
# so converters just lay out an 8 MB linear array as if banking didn't exist.
|
||||
SAMPLE_BANK_SIZE = 524288 # 512 K per bank
|
||||
SAMPLE_BANK_COUNT = 16 # 16 banks × 512 K = 8 MB
|
||||
SAMPLEBIN_SIZE = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT # 8 MB
|
||||
INSTBIN_SIZE = INST_RECORD_SIZE * 256 # 65536 = 64K
|
||||
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
|
||||
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE # 8454144 = 8256 kB
|
||||
PATTERN_ROWS = 64
|
||||
PATTERN_BYTES = PATTERN_ROWS * 8 # 512
|
||||
NUM_PATTERNS_MAX = 4095
|
||||
@@ -41,10 +89,16 @@ NUM_CUES = 1024
|
||||
CUE_SIZE = 32
|
||||
NUM_VOICES = 20
|
||||
|
||||
# Per-sample length cap. Taud instrument records carry the sample length as
|
||||
# a u16 (terranmon.txt:2001+ — bytes 4..5), so any single sample must fit in
|
||||
# 65535 bytes. Converters resample over-long samples individually after the
|
||||
# global pool-overflow pass and rescale the affected channel's TOP_O args.
|
||||
SAMPLE_LEN_LIMIT = 65535
|
||||
|
||||
# Note word sentinels
|
||||
NOTE_NOP = 0xFFFF
|
||||
NOTE_KEYOFF = 0x0000
|
||||
NOTE_CUT = 0xFFFE
|
||||
NOTE_NOP = 0x0000
|
||||
NOTE_KEYOFF = 0x0001
|
||||
NOTE_CUT = 0x0002
|
||||
TAUD_C4 = 0x5000 # The audio engine's Middle C
|
||||
|
||||
# Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms).
|
||||
@@ -73,7 +127,10 @@ TOP_I = 0x12
|
||||
TOP_J = 0x13
|
||||
TOP_K = 0x14
|
||||
TOP_L = 0x15
|
||||
TOP_M = 0x16
|
||||
TOP_N = 0x17
|
||||
TOP_O = 0x18
|
||||
TOP_P = 0x19
|
||||
TOP_Q = 0x1A
|
||||
TOP_R = 0x1B
|
||||
TOP_S = 0x1C
|
||||
@@ -103,6 +160,69 @@ EFF_U = 21; EFF_V = 22; EFF_W = 23; EFF_X = 24; EFF_Y = 25
|
||||
EFF_Z = 26
|
||||
|
||||
|
||||
# ── Envelope offset minifloat ────────────────────────────────────────────────
|
||||
#
|
||||
# Mirror of tsvm_core/.../ThreeFiveMinifloat.kt — used by every *2taud
|
||||
# converter that emits envelope nodes. 3.5 unsigned minifloat (3-bit exponent
|
||||
# + 5-bit mantissa) rebiased so the smallest non-zero step is 1/256 s ≈ 3.91
|
||||
# ms and the maximum is 15.75 s. The previous bias (1/32-step, max 126 s)
|
||||
# under-resolved single-tick deltas at typical tracker BPMs. Every value here
|
||||
# is the original LUT divided by 8.
|
||||
|
||||
MINUFLOAT_LUT = (
|
||||
0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375,
|
||||
0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375,
|
||||
0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375,
|
||||
0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375,
|
||||
0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375,
|
||||
0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375,
|
||||
0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375,
|
||||
0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375,
|
||||
0.25, 0.2578125, 0.265625, 0.2734375, 0.28125, 0.2890625, 0.296875, 0.3046875,
|
||||
0.3125, 0.3203125, 0.328125, 0.3359375, 0.34375, 0.3515625, 0.359375, 0.3671875,
|
||||
0.375, 0.3828125, 0.390625, 0.3984375, 0.40625, 0.4140625, 0.421875, 0.4296875,
|
||||
0.4375, 0.4453125, 0.453125, 0.4609375, 0.46875, 0.4765625, 0.484375, 0.4921875,
|
||||
0.5, 0.515625, 0.53125, 0.546875, 0.5625, 0.578125, 0.59375, 0.609375,
|
||||
0.625, 0.640625, 0.65625, 0.671875, 0.6875, 0.703125, 0.71875, 0.734375,
|
||||
0.75, 0.765625, 0.78125, 0.796875, 0.8125, 0.828125, 0.84375, 0.859375,
|
||||
0.875, 0.890625, 0.90625, 0.921875, 0.9375, 0.953125, 0.96875, 0.984375,
|
||||
1.0, 1.03125, 1.0625, 1.09375, 1.125, 1.15625, 1.1875, 1.21875,
|
||||
1.25, 1.28125, 1.3125, 1.34375, 1.375, 1.40625, 1.4375, 1.46875,
|
||||
1.5, 1.53125, 1.5625, 1.59375, 1.625, 1.65625, 1.6875, 1.71875,
|
||||
1.75, 1.78125, 1.8125, 1.84375, 1.875, 1.90625, 1.9375, 1.96875,
|
||||
2.0, 2.0625, 2.125, 2.1875, 2.25, 2.3125, 2.375, 2.4375,
|
||||
2.5, 2.5625, 2.625, 2.6875, 2.75, 2.8125, 2.875, 2.9375,
|
||||
3.0, 3.0625, 3.125, 3.1875, 3.25, 3.3125, 3.375, 3.4375,
|
||||
3.5, 3.5625, 3.625, 3.6875, 3.75, 3.8125, 3.875, 3.9375,
|
||||
4.0, 4.125, 4.25, 4.375, 4.5, 4.625, 4.75, 4.875,
|
||||
5.0, 5.125, 5.25, 5.375, 5.5, 5.625, 5.75, 5.875,
|
||||
6.0, 6.125, 6.25, 6.375, 6.5, 6.625, 6.75, 6.875,
|
||||
7.0, 7.125, 7.25, 7.375, 7.5, 7.625, 7.75, 7.875,
|
||||
8.0, 8.25, 8.5, 8.75, 9.0, 9.25, 9.5, 9.75,
|
||||
10.0, 10.25, 10.5, 10.75, 11.0, 11.25, 11.5, 11.75,
|
||||
12.0, 12.25, 12.5, 12.75, 13.0, 13.25, 13.5, 13.75,
|
||||
14.0, 14.25, 14.5, 14.75, 15.0, 15.25, 15.5, 15.75,
|
||||
)
|
||||
|
||||
|
||||
def nearest_minifloat(sec: float) -> int:
|
||||
"""Return the ThreeFiveMiniUfloat index (0..255) for the LUT entry nearest to `sec`."""
|
||||
if sec <= 0.0:
|
||||
return 0
|
||||
if sec >= MINUFLOAT_LUT[-1]:
|
||||
return 255
|
||||
lo, hi = 0, len(MINUFLOAT_LUT) - 1
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if MINUFLOAT_LUT[mid] < sec:
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
if lo > 0 and abs(MINUFLOAT_LUT[lo - 1] - sec) < abs(MINUFLOAT_LUT[lo] - sec):
|
||||
return lo - 1
|
||||
return lo
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def d_arg_to_col(arg: int):
|
||||
@@ -165,6 +285,44 @@ def rescale_offset_effects(pat_bin: bytes, ratio: float) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def rescale_offset_effects_per_slot(pat_bin: bytes,
|
||||
num_cues: int,
|
||||
num_channels: int,
|
||||
slot_ratios: dict) -> bytes:
|
||||
"""Scale TOP_O args using a per-slot ratio map.
|
||||
|
||||
`pat_bin` is laid out as `num_cues × num_channels` consecutive
|
||||
PATTERN_BYTES (=512) blocks, channel-minor within each cue. For each
|
||||
channel, walk the rows in cue order and track the most recently
|
||||
written slot byte (row offset 2). When a TOP_O effect appears, scale
|
||||
its arg by `slot_ratios[active_slot]`, falling back to ratio 1.0 if
|
||||
the slot is unknown (e.g. row hits an O before any inst byte has
|
||||
selected a sample for the channel).
|
||||
"""
|
||||
if not pat_bin or not slot_ratios:
|
||||
return pat_bin
|
||||
if all(r == 1.0 for r in slot_ratios.values()):
|
||||
return pat_bin
|
||||
out = bytearray(pat_bin)
|
||||
active = [0] * num_channels
|
||||
for cue in range(num_cues):
|
||||
for ch in range(num_channels):
|
||||
block = (cue * num_channels + ch) * PATTERN_BYTES
|
||||
for row in range(PATTERN_ROWS):
|
||||
rb = block + row * 8
|
||||
inst = out[rb + 2]
|
||||
if inst != 0:
|
||||
active[ch] = inst
|
||||
if out[rb + 5] == TOP_O:
|
||||
ratio = slot_ratios.get(active[ch], 1.0)
|
||||
if ratio != 1.0:
|
||||
arg = out[rb + 6] | (out[rb + 7] << 8)
|
||||
arg = max(0, min(0xFFFF, int(arg * ratio + 0.5)))
|
||||
out[rb + 6] = arg & 0xFF
|
||||
out[rb + 7] = (arg >> 8) & 0xFF
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_cue(patterns12: list, instruction) -> bytearray:
|
||||
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers.
|
||||
|
||||
@@ -253,33 +411,330 @@ def encode_song_entry(song_offset: int, num_voices: int, num_patterns: int,
|
||||
return entry
|
||||
|
||||
|
||||
# ── Subsong detection (multi-song .taud emission) ────────────────────────────
|
||||
#
|
||||
# Modules and trackers don't natively carry a subsong table; subsongs emerge
|
||||
# from the order-list flow graph. OpenMPT-style: take the lowest unvisited
|
||||
# non-terminator order as the next subsong entry, do forward reachability via
|
||||
# fall-through (oi→oi+1) plus pattern-Bxx targets, mark all reached orders
|
||||
# visited, repeat until no entries remain.
|
||||
#
|
||||
# Fall-through is treated as dead when the pattern at oi has a Bxx on its
|
||||
# absolute last row — the convention every tracker uses for "song ends here,
|
||||
# loop back" — which lets non-looping subsongs separated by Bxx-terminated
|
||||
# predecessors be detected even without an explicit 0xFF marker.
|
||||
#
|
||||
# WHEN.s3m → 4 subsongs (0xFF separators); Insaniq2.it → 8 subsongs (Bxx-row-63
|
||||
# terminators, no 0xFF separators). Single-song files collapse to 1 subsong.
|
||||
|
||||
def detect_subsongs(orders, pattern_bxx_fn, *,
|
||||
terminators=(0xFF,), skip_marker=0xFE):
|
||||
"""Detect subsongs by repeated forward reachability.
|
||||
|
||||
Args:
|
||||
orders: list of raw order bytes from the source file. Each element is
|
||||
either a pattern index (0..n-1), a skip value (transparently
|
||||
skipped), or a terminator value (ends a path).
|
||||
pattern_bxx_fn: callable(pattern_idx) → (set_of_bxx_target_order_indices,
|
||||
kills_fallthrough). `kills_fallthrough` is True when the pattern's
|
||||
last row carries a Bxx (unconditional terminator); when False,
|
||||
fall-through to oi+1 is kept as a graph edge.
|
||||
terminators: int, or iterable of ints. Order values that end a path
|
||||
(default 0xFF). Pass an empty iterable for formats without a
|
||||
terminator marker (XM).
|
||||
skip_marker: int, or iterable of ints. Order values that are
|
||||
transparently passed during traversal (default 0xFE). XM passes
|
||||
`range(pattern_count, 256)` to skip out-of-range pattern refs.
|
||||
|
||||
Returns:
|
||||
List of subsongs in entry-order. Each subsong is a dict:
|
||||
'entry': original order-list position of the entry (int)
|
||||
'positions': list of original order-list positions belonging to this
|
||||
subsong, in cue-sheet order (entry first, then ascending index
|
||||
wrap-around). Each position's pattern index = orders[pos].
|
||||
For a single-song file the result has one element whose 'positions'
|
||||
covers the whole order list (minus terminators/skips). For files where
|
||||
every order is a terminator/skip, the result is empty.
|
||||
"""
|
||||
n = len(orders)
|
||||
term = {terminators} if isinstance(terminators, int) else set(terminators)
|
||||
skips = ({skip_marker} if isinstance(skip_marker, int)
|
||||
else set(skip_marker))
|
||||
|
||||
def _is_traversable(pos: int) -> bool:
|
||||
if pos < 0 or pos >= n:
|
||||
return False
|
||||
v = orders[pos]
|
||||
return v not in term and v not in skips
|
||||
|
||||
visited = set()
|
||||
songs = []
|
||||
|
||||
while True:
|
||||
# Lowest unvisited traversable position = next subsong entry.
|
||||
entry = next((i for i in range(n)
|
||||
if i not in visited and _is_traversable(i)), None)
|
||||
if entry is None:
|
||||
break
|
||||
|
||||
# Reachability claims orders for this subsong, stopping at orders
|
||||
# already owned by a previous subsong.
|
||||
owned = set()
|
||||
stack = [entry]
|
||||
while stack:
|
||||
oi = stack.pop()
|
||||
if oi in owned or oi in visited:
|
||||
continue
|
||||
if oi < 0 or oi >= n:
|
||||
continue
|
||||
v = orders[oi]
|
||||
if v in term:
|
||||
continue
|
||||
if v in skips:
|
||||
if oi + 1 < n:
|
||||
stack.append(oi + 1)
|
||||
continue
|
||||
owned.add(oi)
|
||||
tgts, kills = pattern_bxx_fn(v)
|
||||
for t in tgts:
|
||||
if 0 <= t < n:
|
||||
stack.append(t)
|
||||
if not kills and oi + 1 < n:
|
||||
stack.append(oi + 1)
|
||||
|
||||
if not owned:
|
||||
# Avoid infinite loop on a degenerate entry (shouldn't happen
|
||||
# since _is_traversable already filtered terminators / skips).
|
||||
visited.add(entry)
|
||||
continue
|
||||
visited |= owned
|
||||
|
||||
# Cue-sheet order: ascending index, rotated so entry comes first.
|
||||
# The natural order-list traversal is sequential, so increasing index
|
||||
# matches the play sequence when fall-through is alive; rotation
|
||||
# ensures cue 0 is the entry order.
|
||||
sorted_owned = sorted(owned)
|
||||
rot = sorted_owned.index(entry)
|
||||
positions = sorted_owned[rot:] + sorted_owned[:rot]
|
||||
|
||||
songs.append({'entry': entry, 'positions': positions})
|
||||
|
||||
return songs
|
||||
|
||||
|
||||
# ── Project Data section (terranmon.txt:2601+) ───────────────────────────────
|
||||
|
||||
PROJECT_DATA_MAGIC = bytes([0x1E, 0x54, 0x61, 0x75, 0x64, 0x50, 0x72, 0x4A]) # \x1ETaudPrJ
|
||||
PROJECT_DATA_HEADER_SIZE = 16 # 8-byte magic + 8 reserved
|
||||
|
||||
|
||||
def _name_table_blob(names) -> bytes:
|
||||
"""Encode a list of names (slot-indexed; slot 0 is left empty in source) as
|
||||
0x1E-separated UTF-8 bytes. Trailing empty slots are trimmed to save space.
|
||||
Returns b'' when every name is empty.
|
||||
"""
|
||||
if not names:
|
||||
return b''
|
||||
end = len(names)
|
||||
while end > 0 and not names[end - 1]:
|
||||
end -= 1
|
||||
if end == 0:
|
||||
return b''
|
||||
return b'\x1E'.join((n or '').encode('utf-8', 'replace') for n in names[:end])
|
||||
|
||||
|
||||
# ── Ixmp encoder (terranmon.txt §Project Data → Ixmp) ───────────────────────
|
||||
|
||||
# Per-patch byte layout. Field offsets must match AudioJSR223Delegate.uploadInstrumentPatches
|
||||
# (Kotlin parser) and terranmon.txt "Ixmp. Instrument extra samples".
|
||||
IXMP_PATCH_SIZE = 31
|
||||
IXMP_PAN_NO_OVERRIDE = 0xFF
|
||||
IXMP_DNV_NO_OVERRIDE = 0
|
||||
IXMP_VIBWAVE_NO_OVERRIDE = 0xFF
|
||||
|
||||
|
||||
def encode_ixmp_patch(p: dict) -> bytes:
|
||||
"""Encode a single patch dict into 31 bytes.
|
||||
|
||||
Expected keys (numeric values; defaults are applied for missing optional fields):
|
||||
pitch_start, pitch_end : Taud 4096-TET noteVal (Uint16)
|
||||
volume_start, volume_end : 0..63 (Uint8)
|
||||
sample_ptr : Uint32 (sample bin offset)
|
||||
sample_length : Uint16
|
||||
play_start, loop_start, loop_end : Uint16
|
||||
sampling_rate : Uint16 (same encoding as base inst byte 6-7)
|
||||
sample_detune : Int16, signed 4096-TET (default 0)
|
||||
loop_mode : Uint8 (default 0)
|
||||
default_pan : Uint8, 0xFF = no override (default 0xFF)
|
||||
default_note_volume : Uint8 IT-scaled (0 = no override, default 0)
|
||||
vibrato_speed/sweep/depth/rate: Uint8 (default 0)
|
||||
vibrato_waveform : Uint8 (0..7 or 0xFF for no override, default 0xFF)
|
||||
"""
|
||||
pitch_start = max(0, min(0xFFFF, int(p['pitch_start'])))
|
||||
pitch_end = max(0, min(0xFFFF, int(p['pitch_end'])))
|
||||
vol_start = max(0, min(63, int(p.get('volume_start', 0))))
|
||||
vol_end = max(0, min(63, int(p.get('volume_end', 63))))
|
||||
sample_ptr = int(p['sample_ptr']) & 0xFFFFFFFF
|
||||
sample_len = max(0, min(0xFFFF, int(p['sample_length'])))
|
||||
play_start = max(0, min(0xFFFF, int(p.get('play_start', 0))))
|
||||
loop_start = max(0, min(0xFFFF, int(p.get('loop_start', 0))))
|
||||
loop_end = max(0, min(0xFFFF, int(p.get('loop_end', 0))))
|
||||
rate = max(0, min(0xFFFF, int(p.get('sampling_rate', 0))))
|
||||
detune = max(-0x8000, min(0x7FFF, int(p.get('sample_detune', 0))))
|
||||
return struct.pack(
|
||||
'<BHHBBIHHHHHhBBBBBBBB',
|
||||
1, # patch version
|
||||
pitch_start, pitch_end,
|
||||
vol_start, vol_end,
|
||||
sample_ptr,
|
||||
sample_len,
|
||||
play_start, loop_start, loop_end,
|
||||
rate,
|
||||
detune,
|
||||
int(p.get('loop_mode', 0)) & 0x07,
|
||||
int(p.get('default_pan', IXMP_PAN_NO_OVERRIDE)) & 0xFF,
|
||||
int(p.get('default_note_volume', IXMP_DNV_NO_OVERRIDE)) & 0xFF,
|
||||
int(p.get('vibrato_speed', 0)) & 0xFF,
|
||||
int(p.get('vibrato_sweep', 0)) & 0xFF,
|
||||
int(p.get('vibrato_depth', 0)) & 0xFF,
|
||||
int(p.get('vibrato_rate', 0)) & 0xFF,
|
||||
int(p.get('vibrato_waveform', IXMP_VIBWAVE_NO_OVERRIDE)) & 0xFF,
|
||||
)
|
||||
|
||||
|
||||
def encode_ixmp_payload(patches_by_inst: dict) -> bytes:
|
||||
"""Encode a dict {instrument_id: [patch_dict, ...]} as one Ixmp section payload
|
||||
(the body that follows the FourCC + length header). Instruments are written in
|
||||
ascending id order. Overlapping pitch+volume rectangles within one instrument
|
||||
are INVALID per spec and the caller is responsible for keeping them disjoint."""
|
||||
if not patches_by_inst:
|
||||
return b''
|
||||
out = bytearray()
|
||||
for inst_id in sorted(patches_by_inst):
|
||||
patches = patches_by_inst[inst_id]
|
||||
if not patches:
|
||||
continue
|
||||
out.append(int(inst_id) & 0xFF)
|
||||
cnt = len(patches)
|
||||
out += bytes([cnt & 0xFF, (cnt >> 8) & 0xFF, (cnt >> 16) & 0xFF]) # Uint24 LE
|
||||
for patch in patches:
|
||||
out += encode_ixmp_patch(patch)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_project_data(*, project_name: str = '',
|
||||
author: str = '',
|
||||
copyright_str: str = '',
|
||||
sample_names=None,
|
||||
instrument_names=None,
|
||||
pattern_names=None,
|
||||
song_metadata=None,
|
||||
ixmp_patches=None) -> bytes:
|
||||
"""Build the optional PROJECT DATA section payload.
|
||||
|
||||
Returns the full block (8-byte magic + 8 reserved bytes + concatenated
|
||||
FourCC sections), or b'' when there's nothing to write so the caller can
|
||||
leave the header's projOff field at zero.
|
||||
|
||||
`sample_names` / `instrument_names` / `pattern_names` are slot-indexed
|
||||
lists (entry 0 is typically empty since slot 0 is reserved); they are
|
||||
encoded as 0x1E-separated UTF-8 strings inside SNam / INam / pNam blocks.
|
||||
|
||||
`song_metadata` is an optional list of dicts, one per song:
|
||||
{ 'index': int (0..255),
|
||||
'notation': int = 0,
|
||||
'beat_pri': int = 4,
|
||||
'beat_sec': int = 16,
|
||||
'name': str = '',
|
||||
'composer': str = '',
|
||||
'copyright': str = '' }
|
||||
"""
|
||||
sections = []
|
||||
|
||||
def add(fourcc: bytes, payload: bytes) -> None:
|
||||
if not payload:
|
||||
return
|
||||
sections.append(fourcc + struct.pack('<I', len(payload)) + payload)
|
||||
|
||||
if project_name:
|
||||
add(b'PNam', project_name.encode('utf-8', 'replace'))
|
||||
if author:
|
||||
add(b'PCom', author.encode('utf-8', 'replace'))
|
||||
if copyright_str:
|
||||
add(b'PCpr', copyright_str.encode('utf-8', 'replace'))
|
||||
|
||||
add(b'INam', _name_table_blob(instrument_names))
|
||||
add(b'SNam', _name_table_blob(sample_names))
|
||||
add(b'pNam', _name_table_blob(pattern_names))
|
||||
|
||||
if song_metadata:
|
||||
smet = bytearray()
|
||||
for entry in song_metadata:
|
||||
idx = entry.get('index', 0) & 0xFF
|
||||
notation = entry.get('notation', 0) & 0xFFFF
|
||||
beat_pri = entry.get('beat_pri', 4) & 0xFF
|
||||
beat_sec = entry.get('beat_sec', 16) & 0xFF
|
||||
name_b = entry.get('name', '').encode('utf-8', 'replace') + b'\x00'
|
||||
comp_b = entry.get('composer', '').encode('utf-8', 'replace') + b'\x00'
|
||||
copr_b = entry.get('copyright', '').encode('utf-8', 'replace') + b'\x00'
|
||||
payload = (struct.pack('<HBB', notation, beat_pri, beat_sec)
|
||||
+ name_b + comp_b + copr_b)
|
||||
smet.append(idx)
|
||||
smet += struct.pack('<I', len(payload))
|
||||
smet += payload
|
||||
add(b'sMet', bytes(smet))
|
||||
|
||||
if ixmp_patches:
|
||||
add(b'Ixmp', encode_ixmp_payload(ixmp_patches))
|
||||
|
||||
if not sections:
|
||||
return b''
|
||||
|
||||
return PROJECT_DATA_MAGIC + b'\x00' * 8 + b''.join(sections)
|
||||
|
||||
|
||||
# ── Sample normalisation ─────────────────────────────────────────────────────
|
||||
|
||||
def normalise_sample(raw: bytes, signed: bool, is_16bit: bool,
|
||||
is_stereo: bool, name: str) -> bytes:
|
||||
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed."""
|
||||
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed.
|
||||
|
||||
Stereo samples are stored as a split (non-interleaved) layout — the full
|
||||
left channel block followed by the full right channel block — matching the
|
||||
on-disk format used by IT, S3M, and XM (Schism's SF_SS).
|
||||
"""
|
||||
out = []
|
||||
stride = (2 if is_16bit else 1) * (2 if is_stereo else 1)
|
||||
i = 0
|
||||
while i + stride <= len(raw):
|
||||
bps = 2 if is_16bit else 1
|
||||
chans = 2 if is_stereo else 1
|
||||
n_frames = len(raw) // (bps * chans)
|
||||
chan_bytes = n_frames * bps
|
||||
|
||||
for i in range(n_frames):
|
||||
if is_16bit:
|
||||
if is_stereo:
|
||||
l16 = struct.unpack_from('<h', raw, i)[0]
|
||||
r16 = struct.unpack_from('<h', raw, i+2)[0]
|
||||
l16 = struct.unpack_from('<h', raw, i*2)[0]
|
||||
r16 = struct.unpack_from('<h', raw, chan_bytes + i*2)[0]
|
||||
s = (l16 + r16) >> 1
|
||||
else:
|
||||
s = struct.unpack_from('<h', raw, i)[0]
|
||||
s = struct.unpack_from('<h', raw, i*2)[0]
|
||||
v = (s >> 8) + 128
|
||||
else:
|
||||
if is_stereo:
|
||||
l8 = raw[i]; r8 = raw[i+1]
|
||||
raw_s = (l8 + r8) // 2
|
||||
l8 = raw[i]
|
||||
r8 = raw[chan_bytes + i]
|
||||
if signed:
|
||||
l_s = l8 - 256 if l8 >= 0x80 else l8
|
||||
r_s = r8 - 256 if r8 >= 0x80 else r8
|
||||
v = ((l_s + r_s) >> 1) + 128
|
||||
else:
|
||||
v = (l8 + r8) >> 1
|
||||
else:
|
||||
raw_s = raw[i]
|
||||
if signed:
|
||||
v = (raw_s ^ 0x80) & 0xFF
|
||||
else:
|
||||
v = raw_s
|
||||
if signed:
|
||||
v = (raw_s ^ 0x80) & 0xFF
|
||||
else:
|
||||
v = raw_s
|
||||
out.append(v & 0xFF)
|
||||
i += stride
|
||||
if is_16bit or is_stereo:
|
||||
vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)")
|
||||
return bytes(out)
|
||||
|
||||
310
terranmon.txt
310
terranmon.txt
@@ -49,7 +49,13 @@ MMIO
|
||||
0..31 RO: Raw Keyboard Buffer read. Won't shift the key buffer
|
||||
32..33 RO: Mouse X pos
|
||||
34..35 RO: Mouse Y pos
|
||||
36 RO: Mouse down? (1 for TRUE, 0 for FALSE)
|
||||
36 RO: Mouse down?
|
||||
bit 0: left
|
||||
bit 1: right
|
||||
bit 2: middle
|
||||
|
||||
bit 6: wheel up
|
||||
bit 7: wheel down
|
||||
37 RW: Read/Write single key input. Key buffer will be shifted. Manual writing is
|
||||
usually unnecessary as such action must be automatically managed via LibGDX
|
||||
input processing.
|
||||
@@ -1985,18 +1991,14 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
|
||||
|
||||
Memory Space
|
||||
|
||||
0..720895 RW: Sample bin (704k)
|
||||
0..524287 RW: Sample bin window (512k)
|
||||
720896..786431 RW: Instrument bin (256 instruments, 256 bytes each; instrument 0 does nothing; 64k)
|
||||
786432..851967 RW: Play data 1 (currently exposed bank; 64k)
|
||||
851968..917503 RW: Play data 2 (currently exposed bank; 64k)
|
||||
917504..983039 RW: TAD Input Buffer (64k)
|
||||
983040..1048575 RW: TAD Decode Output (64k)
|
||||
|
||||
(Layout note 2026-05-06: sample bin shrunk by 16k and instrument bin widened
|
||||
by the same amount so all downstream dispatch ranges keep their existing
|
||||
anchors at 786432. Total memory space stays at exactly 1 MiB.)
|
||||
|
||||
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample
|
||||
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample. Actual sample memory is 8 MB and are banked. Write to MMIO address 46 to switch banks.
|
||||
|
||||
Instrument bin: Registry for 256 instruments, formatted as:
|
||||
|
||||
@@ -2137,17 +2139,32 @@ from source.
|
||||
(bits 14..15 reserved)
|
||||
21 Bit16x25 Volume envelopes
|
||||
Byte 1: Volume (00..3F)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
71 Bit16x25 Panning envelopes
|
||||
Byte 1: Pan (00..FF)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
121 Bit16x25 Pitch/Filter envelopes
|
||||
Byte 1: Value (00..FF)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
171 Uint8 Instrument Global Volume (0..255)
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
- ImpulseTracker also has samplewise default volume (0..64) and samplewise global volume (0..64), and they must be taken into account because Taud has no samplewise config, following the ImpulseTracker spec
|
||||
* FastTracker2 has range of 0..64; multiply by (255/64) then round to int
|
||||
* Continuous multiplier applied on every output sample (matches IT's
|
||||
`chan->instrument_volume`, see Schism player/csndfile.c:1317 and
|
||||
player/sndmix.c:1171). Independent of the volume column / Mxx /
|
||||
Nxx — the volume column writes the per-note axis (noteVolume),
|
||||
Mxx/Nxx write the per-channel axis (channelVolume); IGV scales
|
||||
the final mix unconditionally and is orthogonal to both.
|
||||
* ImpulseTracker has separate `inst.gv` (0..128) and samplewise
|
||||
`sample.gv` (0..64). Since Taud has no samplewise record, fold
|
||||
the two factors into a single 0..255 value:
|
||||
taud_igv = round(inst.gv * sample.gv * 255 / (128 * 64))
|
||||
The samplewise `sample.vol` (0..64) is NOT folded here — it is the
|
||||
per-trigger default chan_volume in IT (replaceable by V column),
|
||||
and Taud carries it in byte 196 ("Default Note Volume"). Folding
|
||||
it here was the cause of the "low-number voleffs are too quiet"
|
||||
regression (TODO §2350, fixed 2026-05-09).
|
||||
* FastTracker2 has range of 0..64 with no instrumentwise multiplier
|
||||
beyond it; multiply by (255/64) and round. The XM samplewise
|
||||
volume goes into byte 196.
|
||||
172 Uint8 Volume Fadeout low bits
|
||||
173 Bit8 Volume Fadeout high bits
|
||||
0b 0000 ffff
|
||||
@@ -2244,7 +2261,7 @@ from source.
|
||||
* Semantics (matches IT/Schism player/effects.c:1664-1764 csf_check_nna):
|
||||
- Fires on every fresh foreground note trigger on a channel, BEFORE the
|
||||
NNA-spawn step that would ghost the existing voice. Does NOT fire on
|
||||
tone portamento, on note-off (0x0000), on note-cut (0xFFFE), or on
|
||||
tone portamento, on note-off (0x0001), on note-cut (0x0002), or on
|
||||
empty cells.
|
||||
- The DCT/DCA values consulted belong to the EXISTING voice's instrument
|
||||
(i.e. the OLD note's instrument, not the incoming note's). Different
|
||||
@@ -2271,7 +2288,29 @@ from source.
|
||||
triggerNote. So when DCA flags the foreground voice, the NNA-ghost it
|
||||
spawns inherits that DCA-modified state (e.g. noteFading carries over).
|
||||
- The new note then triggers normally on the foreground channel.
|
||||
196..255 Reserved (60 bytes free for future per-instrument fields)
|
||||
196 Uint8 Default Note Volume (0..255)
|
||||
* Per-trigger default for the per-note volume axis (`noteVolume` in
|
||||
the engine, analog of IT's `chan->volume`) when the row carries a
|
||||
fresh note + instrument byte but no explicit volume column (matches
|
||||
IT's `chan->volume = psmp->volume` on note-on, Schism
|
||||
player/effects.c:1302 and :1432). The 8-bit value rescales to
|
||||
Taud's 0..63 note-volume range:
|
||||
note_default = round(default_note_volume * 63 / 255)
|
||||
Any explicit V column SET on the trigger row OVERRIDES this — i.e.
|
||||
noteVolume = vol_value, exactly mirroring IT's "V column replaces
|
||||
chan->volume" rule. The per-channel axis (`channelVolume`, set by
|
||||
Mxx / Nxx) is independent and is NOT reset on re-trigger.
|
||||
* Source-format mapping:
|
||||
- IT: taud_dnv = round(sample.vol * 255 / 64) # 0..64 → 0..255
|
||||
- XM: taud_dnv = round(sample.volume * 255 / 64) # 0..64 → 0..255
|
||||
- S3M: taud_dnv = round(min(inst.volume, 64) * 255 / 64)
|
||||
- MOD: taud_dnv = round(min(sample.volume, 64) * 255 / 64)
|
||||
* .taud files written before 2026-05-09 stored sample.vol folded into
|
||||
byte 171 (IGV) and left this byte zero. Engines reading those older
|
||||
files SHOULD treat default_note_volume == 0 as "field not present"
|
||||
and fall back to row_default = 63 — preserving the pre-fix behaviour
|
||||
for legacy files where IGV already carries sample.vol.
|
||||
197..255 Reserved (59 bytes free for future per-instrument fields)
|
||||
|
||||
|
||||
|
||||
@@ -2293,7 +2332,6 @@ TODO:
|
||||
[x] 4THSYM.it: pitchbend is wrong, some notes keep playing (loudly!) even if new notes are emitted
|
||||
[x] `*2taud.py`: some notes are emitted with wrong volume-set command. Tested with GSLINGER.mod: on order 0x15 channel 1, mod2taud.py emits volume 8 -- also many of the effects are dropped. Suggested solution: currently all converters write default volume to the voleff when original modules (.mod/.s3m/.it) specify nothing; we should also write nothing and let the engine resolve the value just like other trackers do (also we now have "Instrument Global Volume" on instrument definition unlike the other time). This bug may affecting other formats, not just mod2taud.py, as well
|
||||
[x] nearly_there_.mod: `C#5 SD300 / ... / C-5 SD200 / A#4 / G#4 (at tickspeed 4)`: every `C-5 SD200` (there are four occurances) gets skipped
|
||||
[ ] low-number voleffs are too quiet (needs elaboration and test cases)
|
||||
[x] scale Oxxxx when samples get resampled
|
||||
[x] implement bitcrusher and overdrive (eff sym '8' and '9')
|
||||
[x] note trigger with inst and note fx set (e.g. portamento) but no volume set is not getting their default volume but getting what was before instead (SATELL.taud ptn 23) -- and simulateRowState() of taut.js always shows old volume instead of default volume, regardless of note fx's existence
|
||||
@@ -2327,23 +2365,82 @@ TODO:
|
||||
2026-05-06 .taud files predate the P bit and need re-conversion
|
||||
for pan/pf envelopes to play. See byte 15/17/19 spec for the LOOP
|
||||
word bit layout.
|
||||
[ ] implement extended tone mode (MONOTONE compat)
|
||||
[ ] pattern loops stops working after processed once (test with slumberjack.xm)
|
||||
[ ] milkytracker-style volume ramping (on sample-end only)
|
||||
[x] slumberjack.xm: E6x commands are not processed
|
||||
[x] implement linear-freq tone mode (MONOTONE compat)
|
||||
Resolution: ff=2 in song-table flags byte (was reserved). E / F / G
|
||||
arguments are interpreted as Hz/tick at A4 = 440 Hz / C4 ≈ 261.6256 Hz
|
||||
reference, exactly matching MONOTONE's MT_PLAY.PAS `Frequency`
|
||||
arithmetic (MTSRC/MT_PLAY.PAS:606-630). Per-voice `linearFreq` cache
|
||||
in AudioAdapter.kt preserves sub-noteVal precision across ticks; the
|
||||
Voice cache reseeds on note trigger, fine slides, S$2x finetune, and
|
||||
the start of a fresh multi-tick coarse slide. mon2taud.py now emits
|
||||
Hz values verbatim (no SLIDE_UNITS_PER_HZ scaling) and sets the
|
||||
linear-freq flag in the song-table flags byte. Spec details in
|
||||
TAUD_NOTE_EFFECTS.md §1, §E, §F, §G.
|
||||
[x] milkytracker-style volume ramping (on sample-end only)
|
||||
[x] make Cues tab move faster
|
||||
Resolution: Cues panel now uses memory-shift (`shiftOrdersAreaHorizontal`)
|
||||
for LEFT/RIGHT and `shiftPatternArea` for UP/DOWN, plus per-row
|
||||
(`drawOrdersRowAt`) and per-column (`drawOrdersVoiceColumnAt`) helpers,
|
||||
replacing the full-panel redraw on every keystroke.
|
||||
[x] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes.
|
||||
[x] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
|
||||
[x] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). FT2's keyOff (ft2_replayer.c:411-435) zeroes realVol/outVol when the volume envelope is disabled — IT/Schism does not, and Taud's engine follows IT semantics (no fade when fadeStep == 0). Resolved in xm2taud.py: a pre-pass tracks per-channel bound XM instrument across the order-list walk, and any key-off cell whose bound instrument has vol_env_type & XM_ENV_ON == 0 is paired with `SEL_SET vol=0` in the same row. A subsequent vol-col SET on the channel restores audibility — exactly mirroring FT2's outVol/realVol gate without diverging the engine. Engine semantics stay IT-pure.
|
||||
[x] FT2/MOD double effects with 00 as arg (500, 600) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py). This is justified because the volume effects rely on memory when 00 is given, and said memory effect only get recalled when NoteFx is used. TAUD_NOTE_EFFECTS already has detailed implementation notes. Mark those two commands as implemented sorely for tracker compatibility.
|
||||
Also document then implement `Mxx` (set channel volume, not just a note: 0x00 to 0x3F) `Nxy` (channel volume slide: similar to Dxy, but applies to the current channel's volume, not just a note) `Pxy` (channel panning slide. Similar to Dxx: P0y - to the right, Px0 - to the left, PFy - fine pan right, PxF - fine pan left) effects
|
||||
[x] 8 MB sample RAM via 512k banks
|
||||
[x] remove panning mode selection and replace global panning rule to equal energy, also move the 'ff' flags to bit 0..1
|
||||
[x] low-number voleffs are too quiet (resolved 2026-05-09).
|
||||
Root cause: the converters folded IT `sample.vol` into IGV (byte 171),
|
||||
and the engine multiplied by IGV continuously — so any V-column override
|
||||
on a sample with default vol < 64 was attenuated a second time, while
|
||||
IT/Schism's V column replaces `chan->volume` outright (sample.vol does
|
||||
not feature in the continuous `instrument_volume` factor — see
|
||||
player/csndfile.c:1317 and player/sndmix.c:1171).
|
||||
Fix: split the two concepts apart. Byte 171 (IGV) is now pure
|
||||
`inst.gv * sample.gv` continuous multiplier; new byte 196 ("Default
|
||||
Note Volume") carries `sample.vol` and is consulted by triggerNote
|
||||
when no V column is present. Engine + all four `*2taud` converters
|
||||
updated; legacy `.taud` files (byte 196 == 0) fall back to the
|
||||
previous "row volume default = 63" behaviour.
|
||||
[x] physical_presence order 0x1F chn 2: note cuts unexpectedly fast — engine fix
|
||||
[x] GSLINGER order 0x03 chn 1: L 0100 fades unexpectedly fast? — converter fix
|
||||
[x] do not reset tickspeed on pattern view play / add key to modify tick speed ('[' down/']' up)
|
||||
[x] expose song table on UI (test with `insaniq2.taud`)
|
||||
[x] 0x0000 - no-op; 0x0001 - key-off; 0x0002 - note-cut 0x0010..0x001F - INT0..INTF
|
||||
[ ] establish hooks for the interrupts
|
||||
[x] Samples and Instruments view (viewer on taut.js; editor on separate .js)
|
||||
follow the ImpulseTracker design first, then improve from there
|
||||
[?] Sample desig for instrument in Pitch-Volume space (one rectangle = one patch). If undefined, the old sample pointer falls thru
|
||||
[ ] Needs .it and .xm test file to complete it2taud and xm2taud
|
||||
|
||||
TODO - list of demo songs that MUST ship with Microtone:
|
||||
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
||||
(C) Skaven 1998
|
||||
* Slumberjack — for demonstrating XM-compatible instrument definitions
|
||||
(C) raina 2005
|
||||
* Space Debris — MOD with tons of effects
|
||||
(C) Captain/Image 1991
|
||||
* Changing Waves — for Funk Repeat emulation
|
||||
(C) 4mat/orb 2023
|
||||
* Aboriginal Derivatives — for demonstrating Monotone compatibility.
|
||||
(C) Jakim 2010
|
||||
* SWINGIN1 (rename to Swinging Waste) — for demonstrating Monotone compatibility.
|
||||
(C) Phoenix/Hornet 2015
|
||||
|
||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||
|
||||
rr||NOTE|Ins|E.Vol|E.Pan|EE.ff|
|
||||
63||FFFF|255|3 63|3 63|FF FFFF| (8 bytes per line, 512 bytes per pattern, 128 patterns on 64 kB bank, 32 banks available (pattern 0xFFF -- bank 31, pattern 127 is a sentinel value for no-pattern))
|
||||
|
||||
notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using their Sampling rate value.
|
||||
notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using their Sampling rate value. 0x1000: C at zeroth octave; 0xF000: C at 14th octave; 0xFFFF: ~C at 15th octave; 0x0000..0x001F: reserved for sentinels (valid playable note range is 0x0020..0xFFFF)
|
||||
|
||||
Special values:
|
||||
|
||||
note 0xFFFF: no-op
|
||||
note 0xFFFE: note cut
|
||||
note 0x0000: key-off
|
||||
note 0x0000: no-op
|
||||
note 0x0001: key-off
|
||||
note 0x0002: note cut
|
||||
note 0x0010..0x001F: Interrupt 0..F (notation: Int0..IntF) — reserved interrupt slots; engine has no default handler.
|
||||
|
||||
inst 0: no instrument change
|
||||
|
||||
@@ -2374,7 +2471,7 @@ Audio Adapter MMIO
|
||||
Write 16 to initialise the MP2 context (call this before the decoding of NEW music)
|
||||
Write 1 to decode the frame as MP2
|
||||
|
||||
Calling with more than one bit set will result in UNDEFINED BEHAVIOUR
|
||||
Calling with more than one bit set will result in UNDEFINED BEHAVIOUR — except for the flag 0x11, in which the hardware must initialise then immediately start decoding.
|
||||
|
||||
41 RO: MP2 Decoder Status
|
||||
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
||||
@@ -2385,11 +2482,17 @@ Audio Adapter MMIO
|
||||
44 RW: TAD Decoder Status
|
||||
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
||||
45 RW: Select PCM Bin for playhead (writing causes side effects)
|
||||
46 RW: Select current sample bank for tracker, exposed at memory space 0..524287
|
||||
|
||||
64..2367 RW: MP2 Decoded Samples (unsigned 8-bit stereo)
|
||||
2368..4095 RW: MP2 Frame to be decoded
|
||||
4096..4097 RO: MP2 Frame guard bytes; always return 0 on read
|
||||
|
||||
4098..4353 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #0
|
||||
4354..4609 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #1
|
||||
4610..4865 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #2
|
||||
4866..5121 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #3
|
||||
|
||||
Sound Hardware Info
|
||||
- Sampling rate: 32000 Hz
|
||||
- Bit depth: 8 bits/sample, unsigned
|
||||
@@ -2430,14 +2533,14 @@ Play Head Flags
|
||||
Byte 2
|
||||
- PCM Mode: Write non-zero value to start uploading; always 0 when read
|
||||
- Tracker Mode: Global mixer flags. Maps directly to Taud effect symbol '1'
|
||||
0b 0000 00fp
|
||||
p: panning mode (0: linear, 1: equal-power)
|
||||
f: pitchshift mode (0: tone-linear, 1: Amiga)
|
||||
0b 0000 00ff
|
||||
ff: pitchshift mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
|
||||
Tracker command may change the mixer state, but the changes WILL NOT BE REFLECTED BACK.
|
||||
Starting a new song will use whatever written to this register. In other words, changes
|
||||
made by songs will not persist.
|
||||
Panning law is fixed to the equal-energy; there is no runtime selection.
|
||||
Byte 3 (Tracker Mode)
|
||||
- BPM (24 to 279. Play Data will change this register)
|
||||
- BPM (25 to 280. Play Data will change this register)
|
||||
Byte 4 (Tracker Mode)
|
||||
- Tick Rate (Play Data will change this register)
|
||||
|
||||
@@ -2464,40 +2567,45 @@ Play Head Flags
|
||||
|
||||
65536..131071 RW: PCM Sample buffer
|
||||
|
||||
Table of 3.5 Minifloat values (CSV)
|
||||
Table of 3.5 Minifloat values (CSV).
|
||||
Rebiased 2026-05-07 so the smallest non-zero step is 1/256 s and the maximum
|
||||
is 15.75 s — every cell is the original LUT value divided by 8. Chosen for
|
||||
tracker envelopes: a single song tick (≈ 8.9 ms at BPM 280, ≈ 100 ms at
|
||||
BPM 25) now lands within ±17 % of an LUT entry across the whole supported
|
||||
BPM range; the previous bias was ±150 % at common BPMs.
|
||||
,000,001,010,011,100,101,110,111,MSB
|
||||
00000,0,1,2,4,8,16,32,64
|
||||
00001,0.03125,1.03125,2.0625,4.125,8.25,16.5,33,66
|
||||
00010,0.0625,1.0625,2.125,4.25,8.5,17,34,68
|
||||
00011,0.09375,1.09375,2.1875,4.375,8.75,17.5,35,70
|
||||
00100,0.125,1.125,2.25,4.5,9,18,36,72
|
||||
00101,0.15625,1.15625,2.3125,4.625,9.25,18.5,37,74
|
||||
00110,0.1875,1.1875,2.375,4.75,9.5,19,38,76
|
||||
00111,0.21875,1.21875,2.4375,4.875,9.75,19.5,39,78
|
||||
01000,0.25,1.25,2.5,5,10,20,40,80
|
||||
01001,0.28125,1.28125,2.5625,5.125,10.25,20.5,41,82
|
||||
01010,0.3125,1.3125,2.625,5.25,10.5,21,42,84
|
||||
01011,0.34375,1.34375,2.6875,5.375,10.75,21.5,43,86
|
||||
01100,0.375,1.375,2.75,5.5,11,22,44,88
|
||||
01101,0.40625,1.40625,2.8125,5.625,11.25,22.5,45,90
|
||||
01110,0.4375,1.4375,2.875,5.75,11.5,23,46,92
|
||||
01111,0.46875,1.46875,2.9375,5.875,11.75,23.5,47,94
|
||||
10000,0.5,1.5,3,6,12,24,48,96
|
||||
10001,0.53125,1.53125,3.0625,6.125,12.25,24.5,49,98
|
||||
10010,0.5625,1.5625,3.125,6.25,12.5,25,50,100
|
||||
10011,0.59375,1.59375,3.1875,6.375,12.75,25.5,51,102
|
||||
10100,0.625,1.625,3.25,6.5,13,26,52,104
|
||||
10101,0.65625,1.65625,3.3125,6.625,13.25,26.5,53,106
|
||||
10110,0.6875,1.6875,3.375,6.75,13.5,27,54,108
|
||||
10111,0.71875,1.71875,3.4375,6.875,13.75,27.5,55,110
|
||||
11000,0.75,1.75,3.5,7,14,28,56,112
|
||||
11001,0.78125,1.78125,3.5625,7.125,14.25,28.5,57,114
|
||||
11010,0.8125,1.8125,3.625,7.25,14.5,29,58,116
|
||||
11011,0.84375,1.84375,3.6875,7.375,14.75,29.5,59,118
|
||||
11100,0.875,1.875,3.75,7.5,15,30,60,120
|
||||
11101,0.90625,1.90625,3.8125,7.625,15.25,30.5,61,122
|
||||
11110,0.9375,1.9375,3.875,7.75,15.5,31,62,124
|
||||
11111,0.96875,1.96875,3.9375,7.875,15.75,31.5,63,126
|
||||
00000,0,0.125,0.25,0.5,1,2,4,8
|
||||
00001,0.00390625,0.12890625,0.2578125,0.515625,1.03125,2.0625,4.125,8.25
|
||||
00010,0.0078125,0.1328125,0.265625,0.53125,1.0625,2.125,4.25,8.5
|
||||
00011,0.01171875,0.13671875,0.2734375,0.546875,1.09375,2.1875,4.375,8.75
|
||||
00100,0.015625,0.140625,0.28125,0.5625,1.125,2.25,4.5,9
|
||||
00101,0.01953125,0.14453125,0.2890625,0.578125,1.15625,2.3125,4.625,9.25
|
||||
00110,0.0234375,0.1484375,0.296875,0.59375,1.1875,2.375,4.75,9.5
|
||||
00111,0.02734375,0.15234375,0.3046875,0.609375,1.21875,2.4375,4.875,9.75
|
||||
01000,0.03125,0.15625,0.3125,0.625,1.25,2.5,5,10
|
||||
01001,0.03515625,0.16015625,0.3203125,0.640625,1.28125,2.5625,5.125,10.25
|
||||
01010,0.0390625,0.1640625,0.328125,0.65625,1.3125,2.625,5.25,10.5
|
||||
01011,0.04296875,0.16796875,0.3359375,0.671875,1.34375,2.6875,5.375,10.75
|
||||
01100,0.046875,0.171875,0.34375,0.6875,1.375,2.75,5.5,11
|
||||
01101,0.05078125,0.17578125,0.3515625,0.703125,1.40625,2.8125,5.625,11.25
|
||||
01110,0.0546875,0.1796875,0.359375,0.71875,1.4375,2.875,5.75,11.5
|
||||
01111,0.05859375,0.18359375,0.3671875,0.734375,1.46875,2.9375,5.875,11.75
|
||||
10000,0.0625,0.1875,0.375,0.75,1.5,3,6,12
|
||||
10001,0.06640625,0.19140625,0.3828125,0.765625,1.53125,3.0625,6.125,12.25
|
||||
10010,0.0703125,0.1953125,0.390625,0.78125,1.5625,3.125,6.25,12.5
|
||||
10011,0.07421875,0.19921875,0.3984375,0.796875,1.59375,3.1875,6.375,12.75
|
||||
10100,0.078125,0.203125,0.40625,0.8125,1.625,3.25,6.5,13
|
||||
10101,0.08203125,0.20703125,0.4140625,0.828125,1.65625,3.3125,6.625,13.25
|
||||
10110,0.0859375,0.2109375,0.421875,0.84375,1.6875,3.375,6.75,13.5
|
||||
10111,0.08984375,0.21484375,0.4296875,0.859375,1.71875,3.4375,6.875,13.75
|
||||
11000,0.09375,0.21875,0.4375,0.875,1.75,3.5,7,14
|
||||
11001,0.09765625,0.22265625,0.4453125,0.890625,1.78125,3.5625,7.125,14.25
|
||||
11010,0.1015625,0.2265625,0.453125,0.90625,1.8125,3.625,7.25,14.5
|
||||
11011,0.10546875,0.23046875,0.4609375,0.921875,1.84375,3.6875,7.375,14.75
|
||||
11100,0.109375,0.234375,0.46875,0.9375,1.875,3.75,7.5,15
|
||||
11101,0.11328125,0.23828125,0.4765625,0.953125,1.90625,3.8125,7.625,15.25
|
||||
11110,0.1171875,0.2421875,0.484375,0.96875,1.9375,3.875,7.75,15.5
|
||||
11111,0.12109375,0.24609375,0.4921875,0.984375,1.96875,3.9375,7.875,15.75
|
||||
LSB
|
||||
|
||||
## Tracker Note Effects
|
||||
@@ -2513,6 +2621,17 @@ This is a file format for Taud tracker data. Taud can be extended with Microtone
|
||||
|
||||
Endianness: Little
|
||||
|
||||
# Conformance language
|
||||
(RFC 2119+8174)
|
||||
- **MUST** / **MUST NOT** / **REQUIRED** / **SHALL** / **SHALL NOT** — absolute requirements / prohibitions. A conforming implementation **SHALL** observe every such rule; an implementation that violates one is non-conforming.
|
||||
- **SHOULD** / **SHOULD NOT** / **RECOMMENDED** / **NOT RECOMMENDED** — strong guidance. An implementation **MAY** deviate in particular circumstances, but the full implications **MUST** be understood and weighed before doing so.
|
||||
- **MAY** / **OPTIONAL** — truly optional. Implementations that include the feature and implementations that omit it are equally conforming, and each **MUST** be prepared to interoperate with the other (with reduced functionality where the optional feature is the means of interoperation).
|
||||
(IMPLEMENTATION DETAILS)
|
||||
- **INVALID.** Blame the encoder; decoder MUST stop decoding with appropriate errors.
|
||||
- **UNDEFINED BEHAVIOUR.** Encoder MAY encode it; decoder MAY do whatever it wants to, including spawning a daemon out of your nose.
|
||||
- **IGNORED.** Encoder MAY encode it; decoder MUST skip past it.
|
||||
- **RESERVED.** Encoder MUST NOT encode it. Decoder MUST skip past it.
|
||||
|
||||
# File Structure
|
||||
\x1F T S V M a u d
|
||||
[HEADER]
|
||||
@@ -2536,29 +2655,29 @@ Endianness: Little
|
||||
Uint32 Offset to Project Data. Zero if Project Data is nonexistent
|
||||
Byte[14]Tracker/Converter signature
|
||||
|
||||
## Sample and Instrument bin image
|
||||
8256 kB when decompressed. First 8 MB holds samples.
|
||||
|
||||
## Song Table
|
||||
* Rows of 32 bytes:
|
||||
Uint32 Song offset
|
||||
Uint8 Number of voices
|
||||
Uint16 Number of patterns (0 is invalid. pattern bin length = numPats * 8 bytes)
|
||||
Uint8 Initial BPM (bias of -24. 0x00=24, 0xFF=279)
|
||||
Uint8 Initial BPM (bias of -25. 0x00=25, 0xFF=280)
|
||||
Uint8 Initial Tickrate (0 is invalid)
|
||||
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
|
||||
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
|
||||
Uint8 Flags for Global Behaviour (effect symbol '1')
|
||||
0b 0000 0Ffp
|
||||
p: panning law (0=linear, 1=equal-power)
|
||||
Ff: tone mode (0=linear pitch slides, 1=Amiga period slides, 2=linear-frequency slides, 3=reserved)
|
||||
(bit 2 reserved — was 'm' fadeout-zero policy, removed; fadeout
|
||||
scaling now lives entirely in the converter — see byte 172/173
|
||||
of the instrument record for engine semantics)
|
||||
0b 000 rrr ff
|
||||
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: RESERVED)
|
||||
rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: NES DPCM simulation)
|
||||
Uint8 Song global volume
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
Uint8 Song mixing volume
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
Uint32 Compressed size of PATTERN BIN for this song
|
||||
Uint32 Compressed size of CUE SHEET for this song
|
||||
Byte[6] Reserved
|
||||
Byte[6] RESERVED
|
||||
|
||||
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.
|
||||
|
||||
@@ -2580,7 +2699,7 @@ Endianness: Little
|
||||
Project Data is just a concatenation of blocks identified by their FourCC.
|
||||
|
||||
Byte[8] Magic (\x1E T a u d P r J)
|
||||
Byte[8] Reserved
|
||||
Byte[8] RESERVED
|
||||
* Repetition of
|
||||
Byte[4] Title of the section (fourcc)
|
||||
Uint32 Section length
|
||||
@@ -2599,6 +2718,7 @@ prefixes:
|
||||
* PCom. Project author. Encoding: UTF-8
|
||||
* PCpr. Project copyright string. Encoding: UTF-8
|
||||
* PNam. Project name. Encoding: UTF-8
|
||||
* Pmsg. Project message. Encoding: UTF-8
|
||||
|
||||
* INam. Instrument name table. Strings separated by 0x1E
|
||||
|
||||
@@ -2630,10 +2750,11 @@ prefixes:
|
||||
* Repetition of:
|
||||
Uint8 Notation index (starting from zero) used by songs
|
||||
Uint32 Size of this notation following this field
|
||||
Uint16 Reserved for flags
|
||||
Float32 Interval size (octave system = 2.0f). If you are not using an interval system (which means you are responsible for defining every note expressible), this must be NaN. 0f and Infinity are considered illegal
|
||||
Uint16 Notes between interval MINUS ONE (or octave); 12-TET will have value 11
|
||||
Byte[8] Reserved
|
||||
Uint16 RESERVED for flags
|
||||
Uint16 Interval size in 4096-TET lattice (octave = 0x1000, tritave = 0x195C). If you are not using an interval system (which means you are responsible for defining every note expressible), this must be 0.
|
||||
Uint16 RESERVED for float32 interval size (should it be in 4096-TET which is inexact or frequency multiplier which is exact but difficult to implement?)
|
||||
Uint16 Notes between interval (or octave) MINUS ONE; 12-TET will have value 11
|
||||
Byte[8] RESERVED
|
||||
Byte[*] Name, null terminated. Encoding: UTF-8
|
||||
Byte[*] Notation table. 0xFF-separated and null-terminated. Encoding: Taud charset
|
||||
Uint16[*] Frequency table. Size of the table is defined by "Notes between interval MINUS ONE". This is a lookup table of relative pitch offsets (against the base tuning note) in 4096-TET space. Index zero of this table will be 0x0 if you read the spec right
|
||||
@@ -2654,6 +2775,45 @@ prefixes:
|
||||
Uint8 Version (Ascii 'a')
|
||||
Bytes Notation definitions (see above)
|
||||
|
||||
* Ixmp. Instrument extra samples
|
||||
* Repetition of:
|
||||
Uint8 Instrument ID
|
||||
Uint24 Count of patches
|
||||
** Repetition of:
|
||||
Uint8 Patch definition version (always 1)
|
||||
Uint16 Pitch start ; Taud 4096-TET noteVal (same scale as pattern-cell note)
|
||||
Uint16 Pitch end (inclusive)
|
||||
Uint8 Volume start ; 0..63
|
||||
Uint8 Volume end (inclusive) ; 0..63
|
||||
- Above four parameters define a rectangle over the Pitch-Volume space. See Notes 4 and 5
|
||||
Uint32 Sample pointer
|
||||
Uint16 Sample length
|
||||
Uint16 Play Start (usually 0 but not always)
|
||||
Uint16 Loop Start (can be smaller than Play Start)
|
||||
Uint16 Loop End
|
||||
Uint16 samplingRate ; per-sample C-5 speed; same encoding as base instrument byte 6-7
|
||||
Int16 sampleDetune ; per-sample fine detune in signed 4096-TET units (XM finetune; IT samples leave 0)
|
||||
Uint8 loopMode ; same encoding as base instrument byte 14 (bits 0-1 = mode, bit 2 = sustain loop)
|
||||
Uint8 defaultPan ; per-sample default pan (0..255; 0x80 = centre); 0xFF = "no override"
|
||||
Uint8 defaultNoteVolume ; per-sample default note volume (0..255 scaled from IT 0..64); 0 = "no override"
|
||||
Uint8 vibratoSpeed ; per-sample auto-vibrato (mirrors base inst byte 175)
|
||||
Uint8 vibratoSweep ; per-sample auto-vibrato (mirrors base inst byte 176)
|
||||
Uint8 vibratoDepth ; per-sample auto-vibrato (mirrors base inst byte 187)
|
||||
Uint8 vibratoRate ; per-sample auto-vibrato (mirrors base inst byte 188)
|
||||
Uint8 vibratoWaveform ; bits 0-2 only (mirrors instrumentFlag bits 2-4); 0xFF = "no override"
|
||||
|
||||
Notes:
|
||||
0. this extension is made to support IT/XM instrument spec as well as partial compatibility to SF2 (Soundfont format two)
|
||||
1. Envelopes (vol/pan/pf), fadeout, NNA / DCT / DCA, pitch-pan, filter, IGV and any other "instrument-scope" parameters all follow the base instrument definition. Only sample-scope parameters (the patch fields listed above) override.
|
||||
2. overlapping regions are considered INVALID
|
||||
3. multiple Ixmp blocks pointing the same instrument are considered INVALID
|
||||
4. IT and XM does not define volumes. Keep the Volume rectangle at 0..63 — the engine clamps to that range when matching.
|
||||
5. SF2 does define volumes (because MIDI). Convert it using `round(velocity * (63/127))`
|
||||
On import, `initialAttenuation`, filters and ADSR shall be ignored
|
||||
6. Patch selection at trigger time walks the patch list in order; the first patch whose rectangle contains the trigger's (noteVal, rowVolume) wins. When no patch matches, the base instrument's sample fields are used unchanged.
|
||||
7. Sentinel values listed above ("no override") let a patch defer to the base instrument for a given field — used by converters that don't carry per-sample data for one of the dimensions (e.g. SF2 ignoring per-sample pan).
|
||||
8. Total per-patch payload is 31 bytes.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
**S3M (ScreamTracker 3) to Taud conversion notes**
|
||||
@@ -2715,7 +2875,7 @@ The halt instruction (byte value 0x01 at cue offset 30) is placed on the last ac
|
||||
|
||||
## Tempo mapping
|
||||
|
||||
S3M BPM is stored as a raw decimal value. Taud's initial BPM byte uses a bias of -24 (byte 0x00 = 24 BPM, 0xFF = 279 BPM). Conversion: taud_byte = bpm - 24. The converter also scans row 0 of the first pattern in the order list for A (set speed) and T (set tempo) effects and uses those values in preference to the S3M header defaults.
|
||||
S3M BPM is stored as a raw decimal value. Taud's initial BPM byte uses a bias of -25 (byte 0x00 = 25 BPM, 0xFF = 280 BPM). Conversion: taud_byte = bpm - 25. The converter also scans row 0 of the first pattern in the order list for A (set speed) and T (set tempo) effects and uses those values in preference to the S3M header defaults.
|
||||
|
||||
## Global volume
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ import net.torvald.tsvm.peripheral.MP2Env
|
||||
* 8. Call `setCuePosition(playhead, 0)` then `play(playhead)`.
|
||||
*
|
||||
* Note values: 0x4000 = C3 (sample's native pitch), 4096 steps per octave.
|
||||
* Empty row: note = 0xFFFF (no trigger). All 256 instrument slots (0-255) are valid.
|
||||
* Empty row: note = 0x0000 (no trigger). Note sentinels (0x0000..0x001F): 0x0000 = no-op,
|
||||
* 0x0001 = key-off, 0x0002 = note cut, 0x0010..0x001F = Int0..IntF (reserved interrupts).
|
||||
* Valid playable notes are 0x0020..0xFFFF. All 256 instrument slots (0-255) are valid.
|
||||
*
|
||||
* ## How to upload PCM audio into a playhead
|
||||
*
|
||||
@@ -75,7 +77,7 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun startSampleUpload(playhead: Int) { getPlayhead(playhead)?.pcmUpload = true }
|
||||
|
||||
fun setBPM(playhead: Int, bpm: Int) { getPlayhead(playhead)?.bpm = (bpm - 24).and(255) + 24 }
|
||||
fun setBPM(playhead: Int, bpm: Int) { getPlayhead(playhead)?.bpm = (bpm - 25).and(255) + 25 }
|
||||
fun getBPM(playhead: Int) = getPlayhead(playhead)?.bpm
|
||||
|
||||
fun setTickRate(playhead: Int, rate: Int) { getPlayhead(playhead)?.tickRate = rate and 255 }
|
||||
@@ -91,11 +93,157 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun getTrackerRow(playhead: Int) = getPlayhead(playhead)?.trackerState?.rowIndex ?: 0
|
||||
|
||||
/** Mute is now a thin wrapper over the per-voice fader: muting writes 255 (silence),
|
||||
* unmuting clears the fader back to 0 (unity). Callers that want a partial attenuation
|
||||
* should use setVoiceFader directly. */
|
||||
fun setVoiceMute(playhead: Int, voice: Int, muted: Boolean) {
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.muted = muted
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader = if (muted) 255 else 0
|
||||
}
|
||||
fun getVoiceMute(playhead: Int, voice: Int): Boolean =
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.muted ?: false
|
||||
(getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader ?: 0) == 255
|
||||
|
||||
/** Externally-controlled per-voice fader. 0 = unity, 255 = silence; values are masked to 8 bits.
|
||||
* Mirrors MMIO 4098.. (256 bytes per playhead, first 20 entries map to live voice slots). */
|
||||
fun setVoiceFader(playhead: Int, voice: Int, fader: Int) {
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader = fader and 255
|
||||
}
|
||||
fun getVoiceFader(playhead: Int, voice: Int): Int =
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader ?: 0
|
||||
|
||||
/** Effective per-voice tracker volume (0.0..1.0) — what the mixer applies right now after the
|
||||
* envelope, fadeout, vol-column / D-slide / tremolo ramp, and the host-owned per-voice fader,
|
||||
* but BEFORE master/mixing/global volumes. Returns 0.0 for inactive voices. Mirrors the
|
||||
* perVoiceGain assembled in the per-sample mix loop (AudioAdapter.kt:3201). */
|
||||
fun getVoiceEffectiveVolume(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
val effEnvVol = if (v.volEnvOn) v.envVolMix else 1.0
|
||||
val faderGain = (255 - v.fader) / 255.0
|
||||
return (effEnvVol * v.fadeoutVolume * v.currentMixVolume * faderGain).coerceIn(0.0, 1.0)
|
||||
}
|
||||
|
||||
/** Effective per-voice tracker pan (0..255, 128 = centre) — channelPan modulated by the pan
|
||||
* envelope when it is active. Returns 128 (centre) for inactive voices. Mirrors the pan
|
||||
* selection in the per-sample mix loop (AudioAdapter.kt:3205). */
|
||||
fun getVoiceEffectivePan(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 128
|
||||
if (!v.active) return 128
|
||||
return if (v.hasPanEnv && v.panEnvOn) {
|
||||
val envPanRaw = (v.envPan * 255.0).toInt().coerceIn(0, 255)
|
||||
(v.channelPan + envPanRaw - 128).coerceIn(0, 255)
|
||||
} else v.channelPan.coerceIn(0, 255)
|
||||
}
|
||||
|
||||
/** Whether the voice slot is currently sounding (i.e. owns an active sample). Mirrors
|
||||
* `Voice.active` which is the source of truth for "is this voice contributing to the mix
|
||||
* right now". Visualisers should treat this as the authoritative on/off bit. */
|
||||
fun getVoiceActive(playhead: Int, voice: Int): Boolean =
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.active == true
|
||||
|
||||
/** Active-note counts per instrument id (index 0..255): how many notes are sounding *right
|
||||
* now* for each instrument, counting ~~BOTH~~ the live foreground voices ~~and the NNA background
|
||||
* ghosts in the mixer-private pool~~~. Lets visualisers colour by polyphony. The ghost pool is
|
||||
* mutated by the render thread, so it is read defensively by index and any transient
|
||||
* inconsistency is tolerated (a single best-effort frame). */
|
||||
fun getActiveNoteCounts(playhead: Int): IntArray {
|
||||
val counts = IntArray(256)
|
||||
val ts = getPlayhead(playhead)?.trackerState ?: return counts
|
||||
for (v in ts.voices) {
|
||||
if (v.active) counts[v.instrumentId and 0xFF]++
|
||||
}
|
||||
// disabling NNA for now
|
||||
/*try {
|
||||
val bg = ts.backgroundVoices
|
||||
for (i in 0 until bg.size) {
|
||||
val v = bg.getOrNull(i) ?: continue
|
||||
if (v.active) counts[v.instrumentId and 0xFF]++
|
||||
}
|
||||
} catch (_: Exception) { /* ghost pool mutated mid-read — counts are best-effort */ }
|
||||
*/
|
||||
return counts
|
||||
}
|
||||
|
||||
/** Funk-repeat (S$Fx) speed currently driving the voice: 0 = off, otherwise the per-tick
|
||||
* accumulator increment. A non-zero value on an active voice means the voice is live-inverting
|
||||
* its instrument's loop region right now — visualisers can use this to gate the funk overlay. */
|
||||
fun getVoiceFunkSpeed(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
|
||||
if (!v.active) return 0
|
||||
return v.funkSpeed
|
||||
}
|
||||
|
||||
/** Snapshot of an instrument's funk-repeat XOR mask (one bit per loop-region byte; a set bit
|
||||
* flips that byte by 0xFF during playback). Returns the mask bytes as ints (0..255), or an
|
||||
* empty array when the instrument has never been funk-repeated. The render thread mutates the
|
||||
* live mask, so this returns a copy — the caller gets a stable single-frame view. */
|
||||
fun getInstrumentFunkMask(slot: Int): IntArray {
|
||||
val mask = getFirstSnd()?.instruments?.get(slot and 0xFF)?.funkMask ?: return IntArray(0)
|
||||
return IntArray(mask.size) { mask[it].toInt() and 0xFF }
|
||||
}
|
||||
|
||||
/** Live noteVal (0..65535, 4096-TET) of the foreground voice — the value the mixer is using
|
||||
* *right now* including any in-flight vibrato / arpeggio / portamento delta. Returns 0 for
|
||||
* inactive voices. */
|
||||
fun getVoiceNote(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
|
||||
if (!v.active) return 0
|
||||
return v.noteVal and 0xFFFF
|
||||
}
|
||||
|
||||
/** Instrument id (0..255) currently bound to the voice slot, or 0 if the voice is inactive. */
|
||||
fun getVoiceInstrument(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
|
||||
if (!v.active) return 0
|
||||
return v.instrumentId and 0xFF
|
||||
}
|
||||
|
||||
/** Current sample-frame playback position (fractional double) of the voice. Returns -1.0
|
||||
* when the voice is inactive so visualisers can distinguish "no cursor" from "cursor at 0". */
|
||||
fun getVoiceSamplePos(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1.0
|
||||
if (!v.active) return -1.0
|
||||
return v.samplePos
|
||||
}
|
||||
|
||||
/** Volume-envelope segment index — i.e. the node the voice is currently moving *away* from
|
||||
* (the next node it will hit is index + 1). Returns -1 when inactive. */
|
||||
fun getVoiceEnvVolIndex(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.envIndex
|
||||
}
|
||||
/** Seconds elapsed *into* the current volume-envelope segment (0 ≤ t < segment.offset). */
|
||||
fun getVoiceEnvVolTime(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
return v.envTimeSec
|
||||
}
|
||||
|
||||
/** Pan-envelope segment index — see [getVoiceEnvVolIndex]. */
|
||||
fun getVoiceEnvPanIndex(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.envPanIndex
|
||||
}
|
||||
/** Seconds elapsed into the current pan-envelope segment. */
|
||||
fun getVoiceEnvPanTime(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
return v.envPanTimeSec
|
||||
}
|
||||
|
||||
/** Pitch/filter-envelope segment index — see [getVoiceEnvVolIndex]. */
|
||||
fun getVoiceEnvPitchIndex(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.envPfIndex
|
||||
}
|
||||
/** Seconds elapsed into the current pitch/filter-envelope segment. */
|
||||
fun getVoiceEnvPitchTime(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
return v.envPfTimeSec
|
||||
}
|
||||
|
||||
/** Set the starting row for the next play call, resetting per-row timing and silencing active voices. */
|
||||
fun setTrackerRow(playhead: Int, row: Int) {
|
||||
@@ -117,6 +265,64 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload an Ixmp "extra samples" block for instrument [slot] (0-255). The payload is
|
||||
* a flat byte array of `count × 31` patch records — see terranmon.txt "Ixmp. Instrument
|
||||
* extra samples" for the on-wire field layout. Passing an empty array clears any
|
||||
* previously-installed patches on this instrument. */
|
||||
fun uploadInstrumentPatches(slot: Int, bytes: IntArray) {
|
||||
val inst = getFirstSnd()?.instruments?.get(slot and 0xFF) ?: return
|
||||
val recordSize = 31
|
||||
if (bytes.isEmpty() || bytes.size < recordSize) {
|
||||
inst.extraPatches = null
|
||||
return
|
||||
}
|
||||
val count = bytes.size / recordSize
|
||||
if (count == 0) { inst.extraPatches = null; return }
|
||||
fun u8 (o: Int) = bytes[o] and 0xFF
|
||||
fun u16(o: Int) = (bytes[o] and 0xFF) or ((bytes[o + 1] and 0xFF) shl 8)
|
||||
fun s16(o: Int): Int { val v = u16(o); return if (v >= 0x8000) v - 0x10000 else v }
|
||||
fun u32(o: Int) = (bytes[o] and 0xFF) or
|
||||
((bytes[o + 1] and 0xFF) shl 8) or
|
||||
((bytes[o + 2] and 0xFF) shl 16) or
|
||||
((bytes[o + 3] and 0xFF) shl 24)
|
||||
val patches = Array(count) { i ->
|
||||
val o = i * recordSize
|
||||
// Patch version byte at offset 0 is parsed but only version 1 is recognised;
|
||||
// a future version bump would gate alternate field layouts here.
|
||||
AudioAdapter.TaudInstPatch(
|
||||
pitchStart = u16(o + 1),
|
||||
pitchEnd = u16(o + 3),
|
||||
volumeStart = u8 (o + 5),
|
||||
volumeEnd = u8 (o + 6),
|
||||
samplePtr = u32(o + 7),
|
||||
sampleLength = u16(o + 11),
|
||||
playStart = u16(o + 13),
|
||||
loopStart = u16(o + 15),
|
||||
loopEnd = u16(o + 17),
|
||||
samplingRate = u16(o + 19),
|
||||
sampleDetune = s16(o + 21),
|
||||
loopMode = u8 (o + 23),
|
||||
defaultPan = u8 (o + 24),
|
||||
defaultNoteVolume = u8 (o + 25),
|
||||
vibratoSpeed = u8 (o + 26),
|
||||
vibratoSweep = u8 (o + 27),
|
||||
vibratoDepth = u8 (o + 28),
|
||||
vibratoRate = u8 (o + 29),
|
||||
vibratoWaveform = u8 (o + 30)
|
||||
)
|
||||
}
|
||||
inst.extraPatches = patches
|
||||
}
|
||||
|
||||
/** Number of Ixmp patches currently installed on instrument [slot], or 0 if none. */
|
||||
fun getInstrumentPatchCount(slot: Int): Int =
|
||||
getFirstSnd()?.instruments?.get(slot and 0xFF)?.extraPatches?.size ?: 0
|
||||
|
||||
/** Clear any Ixmp patches previously uploaded to instrument [slot]. */
|
||||
fun clearInstrumentPatches(slot: Int) {
|
||||
getFirstSnd()?.instruments?.get(slot and 0xFF)?.extraPatches = null
|
||||
}
|
||||
|
||||
/** Upload 512 bytes (64 rows × 8 bytes) defining pattern `slot` (0-4094). */
|
||||
fun uploadPattern(slot: Int, bytes: IntArray) {
|
||||
getFirstSnd()?.playdata?.get(slot and 0xFFF)?.let { pat ->
|
||||
@@ -134,12 +340,7 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
fun setTrackerMixerFlags(playhead: Int, flags: Int) {
|
||||
getFirstSnd()?.playheads?.get(playhead)?.let { ph ->
|
||||
ph.initialGlobalFlags = flags
|
||||
ph.trackerState?.let { ts ->
|
||||
ts.panLaw = flags and 1
|
||||
ts.amigaMode = (flags and 2) != 0
|
||||
// bit 2 reserved (was 'm' fadeout-zero policy; removed — see AudioAdapter.kt
|
||||
// and TAUD_NOTE_EFFECTS.md §1 "Volume Fadeout")
|
||||
}
|
||||
ph.updateTrackerGlobalBehaviour(flags)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +373,13 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
getPlayhead(playhead)?.resetParams()
|
||||
}
|
||||
|
||||
/** Clear funk-repeat (S$Fx) state (per-voice run-state + per-instrument loop-inversion masks)
|
||||
* without disturbing tempo / volume / position. Call on a fresh play-from-start so stale funk
|
||||
* state from a prior playback doesn't bleed into the replay. */
|
||||
fun resetFunkState(playhead: Int) {
|
||||
getPlayhead(playhead)?.resetFunkState()
|
||||
}
|
||||
|
||||
fun purgeQueue(playhead: Int) {
|
||||
getPlayhead(playhead)?.purgeQueue()
|
||||
}
|
||||
@@ -185,6 +393,61 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun getBaseAddr(): Int? = getFirstSnd()?.let { return it.vm.findPeriSlotNum(it)?.times(-131072)?.minus(1) }
|
||||
fun getMemAddr(): Int? = getFirstSnd()?.let { return it.vm.findPeriSlotNum(it)?.times(-1048576)?.minus(1) }
|
||||
|
||||
/** Switch the sample-bin window (peripheral memory 0..524287) to bank `bank` (0..15).
|
||||
* The 8 MB sample pool is organised as 16 × 512 K banks; only the selected bank
|
||||
* is visible through the window. (terranmon.txt:1985-1997, MMIO 46.) */
|
||||
fun setSampleBank(bank: Int) { getFirstSnd()?.mmio_write(46L, bank.toByte()) }
|
||||
fun getSampleBank(): Int? = getFirstSnd()?.sampleBank
|
||||
|
||||
/** Decompress a Taud sample+instrument blob (gzip or zstd) directly into the
|
||||
* audio adapter's 8 MB sample pool and 64 K instrument bin, bypassing the user
|
||||
* memory staging buffer. The decompressed payload must be exactly
|
||||
* `SAMPLE_BIN_TOTAL + 65536` bytes (8 MB samples followed by 64 K instruments).
|
||||
*
|
||||
* Needed because user space is capped at 8 MB and cannot hold the full 8256 kB
|
||||
* decompressed image as a contiguous buffer. */
|
||||
fun uploadSampleInstBlob(srcPtr: Int, srcLen: Int): Int {
|
||||
val snd = getFirstSnd() ?: return 0
|
||||
val inbytes = ByteArray(srcLen) { vm.peek(srcPtr.toLong() + it)!! }
|
||||
val bytes = CompressorDelegate.decomp(inbytes)
|
||||
val sampleSize = AudioAdapter.SAMPLE_BIN_TOTAL.toInt()
|
||||
val instSize = 65536
|
||||
if (bytes.size < sampleSize + instSize) return 0
|
||||
UnsafeHelper.memcpyRaw(
|
||||
bytes, UnsafeHelper.getArrayOffset(bytes),
|
||||
null, snd.sampleBin.ptr,
|
||||
sampleSize.toLong()
|
||||
)
|
||||
for (i in 0 until instSize) {
|
||||
snd.instruments[i / 256].setByte(i % 256, bytes[sampleSize + i].toInt() and 0xFF)
|
||||
}
|
||||
return bytes.size
|
||||
}
|
||||
|
||||
/** Compress the audio adapter's full 8 MB sample pool + 64 K instrument bin
|
||||
* (8256 kB total) and write the resulting gzip/zstd blob to user-memory `dstPtr`.
|
||||
* Returns the compressed size. The caller must ensure `dstMaxLen` is large
|
||||
* enough; for incompressible noise the worst case is ~8.3 MB which exceeds
|
||||
* user space — but realistic sample data compresses easily. */
|
||||
fun captureSampleInstBlob(dstPtr: Int, dstMaxLen: Int): Int {
|
||||
val snd = getFirstSnd() ?: return 0
|
||||
val sampleSize = AudioAdapter.SAMPLE_BIN_TOTAL.toInt()
|
||||
val instSize = 65536
|
||||
val raw = ByteArray(sampleSize + instSize)
|
||||
UnsafeHelper.memcpyRaw(
|
||||
null, snd.sampleBin.ptr,
|
||||
raw, UnsafeHelper.getArrayOffset(raw),
|
||||
sampleSize.toLong()
|
||||
)
|
||||
for (i in 0 until instSize) {
|
||||
raw[sampleSize + i] = snd.instruments[i / 256].getByte(i % 256)
|
||||
}
|
||||
val compressed = CompressorDelegate.comp(raw)
|
||||
val n = minOf(compressed.size, dstMaxLen)
|
||||
for (i in 0 until n) vm.poke((dstPtr + i).toLong(), compressed[i])
|
||||
return compressed.size
|
||||
}
|
||||
fun mp2Init() = getFirstSnd()?.mmio_write(40L, 16)
|
||||
fun mp2Decode() = getFirstSnd()?.mmio_write(40L, 1)
|
||||
fun mp2InitThenDecode() = getFirstSnd()?.mmio_write(40L, 17)
|
||||
@@ -225,6 +488,7 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
|
||||
|
||||
// while the following code does work, it was decided that MP3 is "too new" for tsvm and thus removed.
|
||||
/*
|
||||
js-mp3
|
||||
https://github.com/soundbus-technologies/js-mp3
|
||||
|
||||
@@ -149,6 +149,90 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
}
|
||||
|
||||
fun plotRect(x: Int, y: Int, w: Int, h: Int, colour: Int) = plotRect(x, y, w, h, colour, 0)
|
||||
|
||||
/**
|
||||
* @param eff plot effect. 0 — solid, 1 — 50% checkerboard, 2 — 25% checkerboard
|
||||
*/
|
||||
fun plotRect(x: Int, y: Int, w: Int, h: Int, colour: Int, eff: Int) {
|
||||
val xs = min(x, x+w).toLong()
|
||||
val xe = max(x, x+w).toLong()
|
||||
val ys = min(y, y+h).toLong()
|
||||
val ye = max(y, y+h).toLong()
|
||||
|
||||
getFirstGPU()?.let {
|
||||
val forYcond = if (eff == 2) (ys until ye step 2) else (ys until ye)
|
||||
|
||||
for (py in forYcond) {
|
||||
when (eff) {
|
||||
0 -> for (px in xs until xe) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
val parity = py % 2
|
||||
val forXcond = if (parity == 0L) (xs until xe step 2) else ((xs+1) until xe step 2)
|
||||
|
||||
for (px in forXcond) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> for (px in xs until xe step 2) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
it.applyDelay()
|
||||
}
|
||||
}
|
||||
|
||||
fun plotRect2(x: Int, y: Int, w: Int, h: Int, colour: Int) = plotRect2(x, y, w, h, colour, 0)
|
||||
|
||||
/**
|
||||
* @param eff plot effect. 0 — solid, 1 — 50% checkerboard, 2 — 25% checkerboard
|
||||
*/
|
||||
fun plotRect2(x: Int, y: Int, w: Int, h: Int, colour: Int, eff: Int) {
|
||||
val xs = min(x, x+w).toLong()
|
||||
val xe = max(x, x+w).toLong()
|
||||
val ys = min(y, y+h).toLong()
|
||||
val ye = max(y, y+h).toLong()
|
||||
|
||||
getFirstGPU()?.let {
|
||||
val forYcond = if (eff == 2) (ys until ye step 2) else (ys until ye)
|
||||
|
||||
for (py in forYcond) {
|
||||
when (eff) {
|
||||
0 -> for (px in xs until xe) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(262144 + py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
val parity = py % 2
|
||||
val forXcond = if (parity == 0L) (xs until xe step 2) else ((xs+1) until xe step 2)
|
||||
|
||||
for (px in forXcond) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> for (px in xs until xe step 2) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
it.applyDelay()
|
||||
}
|
||||
}
|
||||
|
||||
fun plotPixelMode1(x: Int, y: Int, colour: Int, plane: Int) {
|
||||
getFirstGPU()?.let {
|
||||
val planesize = it.config.width * it.config.height / 4
|
||||
@@ -159,6 +243,51 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
}
|
||||
|
||||
fun plotRectMode1(x: Int, y: Int, w: Int, h: Int, colour: Int, plane: Int) = plotRectMode1(x, y, w, h, colour, plane, 0)
|
||||
|
||||
/**
|
||||
* @param eff plot effect. 0 — solid, 1 — 50% checkerboard, 2 — 25% checkerboard
|
||||
*/
|
||||
fun plotRectMode1(x: Int, y: Int, w: Int, h: Int, colour: Int, plane: Int, eff: Int) {
|
||||
val xs = min(x, x+w).toLong()
|
||||
val xe = max(x, x+w).toLong()
|
||||
val ys = min(y, y+h).toLong()
|
||||
val ye = max(y, y+h).toLong()
|
||||
|
||||
getFirstGPU()?.let {
|
||||
val halfW = it.config.width / 2
|
||||
val halfH = it.config.height / 2
|
||||
val planesize = it.config.width * it.config.height / 4
|
||||
val forYcond = if (eff == 2) (ys until ye step 2) else (ys until ye)
|
||||
|
||||
for (py in forYcond) {
|
||||
when (eff) {
|
||||
0 -> for (px in xs until xe) {
|
||||
if (px in 0 until halfW && py in 0 until halfH) {
|
||||
it.poke(py * halfW + px + planesize * plane, colour.toByte())
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
val parity = py % 2
|
||||
val forXcond = if (parity == 0L) (xs until xe step 2) else ((xs+1) until xe step 2)
|
||||
|
||||
for (px in forXcond) {
|
||||
if (px in 0 until halfW && py in 0 until halfH) {
|
||||
it.poke(py * halfW + px + planesize * plane, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> for (px in xs until xe step 2) {
|
||||
if (px in 0 until halfW && py in 0 until halfH) {
|
||||
it.poke(py * halfW + px + planesize * plane, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
it.applyDelay()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets absolute position of scrolling
|
||||
*/
|
||||
@@ -5433,6 +5562,18 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
|
||||
private val TAV_QLUT = intArrayOf(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,102,104,106,108,110,112,114,116,118,120,122,124,126,128,132,136,140,144,148,152,156,160,164,168,172,176,180,184,188,192,196,200,204,208,212,216,220,224,228,232,236,240,244,248,252,256,264,272,280,288,296,304,312,320,328,336,344,352,360,368,376,384,392,400,408,416,424,432,440,448,456,464,472,480,488,496,504,512,528,544,560,576,592,608,624,640,656,672,688,704,720,736,752,768,784,800,816,832,848,864,880,896,912,928,944,960,976,992,1008,1024,1056,1088,1120,1152,1184,1216,1248,1280,1312,1344,1376,1408,1440,1472,1504,1536,1568,1600,1632,1664,1696,1728,1760,1792,1824,1856,1888,1920,1952,1984,2016,2048,2112,2176,2240,2304,2368,2432,2496,2560,2624,2688,2752,2816,2880,2944,3008,3072,3136,3200,3264,3328,3392,3456,3520,3584,3648,3712,3776,3840,3904,3968,4032,4096)
|
||||
|
||||
// Zstd magic = 0x28 0xB5 0x2F 0xFD (little-endian frame magic).
|
||||
// Newer TAV files default to no Zstd (Video Flags bit 4); detecting the magic
|
||||
// lets the decoder accept both compressed and raw payloads transparently.
|
||||
private fun tavDecompressIfZstd(data: ByteArray): ByteArray {
|
||||
if (data.size >= 4 &&
|
||||
data[0] == 0x28.toByte() && data[1] == 0xB5.toByte() &&
|
||||
data[2] == 0x2F.toByte() && data[3] == 0xFD.toByte()) {
|
||||
return ZstdInputStream(ByteArrayInputStream(data)).use { it.readBytes() }
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// New tavDecode function that accepts compressed data and decompresses internally
|
||||
fun tavDecodeCompressed(compressedDataPtr: Long, compressedSize: Int, currentRGBAddr: Long, prevRGBAddr: Long,
|
||||
width: Int, height: Int, qIndex: Int, qYGlobal: Int, qCoGlobal: Int, qCgGlobal: Int, channelLayout: Int,
|
||||
@@ -5445,12 +5586,9 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
|
||||
return try {
|
||||
// Decompress using Zstd
|
||||
val bais = ByteArrayInputStream(compressedData)
|
||||
val zis = ZstdInputStream(bais)
|
||||
val decompressedData = zis.readBytes()
|
||||
zis.close()
|
||||
bais.close()
|
||||
// Decompress with Zstd if the payload starts with the Zstd frame magic;
|
||||
// otherwise pass through (TAV files written without --zstd-level).
|
||||
val decompressedData = tavDecompressIfZstd(compressedData)
|
||||
|
||||
// Allocate buffer for decompressed data
|
||||
val decompressedBuffer = vm.malloc(decompressedData.size)
|
||||
@@ -6725,9 +6863,9 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
)
|
||||
|
||||
val decompressedData = try {
|
||||
ZstdInputStream(java.io.ByteArrayInputStream(compressedData)).use { zstd ->
|
||||
zstd.readBytes()
|
||||
}
|
||||
// Decompress with Zstd if the payload starts with the Zstd frame magic;
|
||||
// otherwise pass through (TAV files written without --zstd-level).
|
||||
tavDecompressIfZstd(compressedData)
|
||||
} catch (e: Exception) {
|
||||
println("ERROR: Zstd decompression failed: ${e.message}")
|
||||
return arrayOf(0, dbgOut)
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
package net.torvald.tsvm
|
||||
|
||||
/**
|
||||
* Created by minjaesong on 2022-12-30.
|
||||
* 3.5 unsigned minifloat (3-bit exponent + 5-bit mantissa), scaled so the
|
||||
* smallest non-zero step is 1/256 s ≈ 3.91 ms and the maximum representable
|
||||
* value is 15.75 s. Used for Taud envelope point offsets — the resolution at
|
||||
* the low end is fine enough to resolve individual tracker ticks at every
|
||||
* supported BPM (worst case ±17 % at BPM 250+, vs. ±150 % under the original
|
||||
* 1/32-step bias).
|
||||
*
|
||||
* Created by minjaesong on 2022-12-30. Rebiased for tracker tick resolution
|
||||
* on 2026-05-07 (entire LUT divided by 8).
|
||||
*/
|
||||
@JvmInline
|
||||
value class ThreeFiveMiniUfloat(val index: Int = 0) {
|
||||
@@ -11,7 +19,7 @@ value class ThreeFiveMiniUfloat(val index: Int = 0) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
val LUT = floatArrayOf(0f,0.03125f,0.0625f,0.09375f,0.125f,0.15625f,0.1875f,0.21875f,0.25f,0.28125f,0.3125f,0.34375f,0.375f,0.40625f,0.4375f,0.46875f,0.5f,0.53125f,0.5625f,0.59375f,0.625f,0.65625f,0.6875f,0.71875f,0.75f,0.78125f,0.8125f,0.84375f,0.875f,0.90625f,0.9375f,0.96875f,1f,1.03125f,1.0625f,1.09375f,1.125f,1.15625f,1.1875f,1.21875f,1.25f,1.28125f,1.3125f,1.34375f,1.375f,1.40625f,1.4375f,1.46875f,1.5f,1.53125f,1.5625f,1.59375f,1.625f,1.65625f,1.6875f,1.71875f,1.75f,1.78125f,1.8125f,1.84375f,1.875f,1.90625f,1.9375f,1.96875f,2f,2.0625f,2.125f,2.1875f,2.25f,2.3125f,2.375f,2.4375f,2.5f,2.5625f,2.625f,2.6875f,2.75f,2.8125f,2.875f,2.9375f,3f,3.0625f,3.125f,3.1875f,3.25f,3.3125f,3.375f,3.4375f,3.5f,3.5625f,3.625f,3.6875f,3.75f,3.8125f,3.875f,3.9375f,4f,4.125f,4.25f,4.375f,4.5f,4.625f,4.75f,4.875f,5f,5.125f,5.25f,5.375f,5.5f,5.625f,5.75f,5.875f,6f,6.125f,6.25f,6.375f,6.5f,6.625f,6.75f,6.875f,7f,7.125f,7.25f,7.375f,7.5f,7.625f,7.75f,7.875f,8f,8.25f,8.5f,8.75f,9f,9.25f,9.5f,9.75f,10f,10.25f,10.5f,10.75f,11f,11.25f,11.5f,11.75f,12f,12.25f,12.5f,12.75f,13f,13.25f,13.5f,13.75f,14f,14.25f,14.5f,14.75f,15f,15.25f,15.5f,15.75f,16f,16.5f,17f,17.5f,18f,18.5f,19f,19.5f,20f,20.5f,21f,21.5f,22f,22.5f,23f,23.5f,24f,24.5f,25f,25.5f,26f,26.5f,27f,27.5f,28f,28.5f,29f,29.5f,30f,30.5f,31f,31.5f,32f,33f,34f,35f,36f,37f,38f,39f,40f,41f,42f,43f,44f,45f,46f,47f,48f,49f,50f,51f,52f,53f,54f,55f,56f,57f,58f,59f,60f,61f,62f,63f,64f,66f,68f,70f,72f,74f,76f,78f,80f,82f,84f,86f,88f,90f,92f,94f,96f,98f,100f,102f,104f,106f,108f,110f,112f,114f,116f,118f,120f,122f,124f,126f)
|
||||
val LUT = floatArrayOf(0f,0.00390625f,0.0078125f,0.01171875f,0.015625f,0.01953125f,0.0234375f,0.02734375f,0.03125f,0.03515625f,0.0390625f,0.04296875f,0.046875f,0.05078125f,0.0546875f,0.05859375f,0.0625f,0.06640625f,0.0703125f,0.07421875f,0.078125f,0.08203125f,0.0859375f,0.08984375f,0.09375f,0.09765625f,0.1015625f,0.10546875f,0.109375f,0.11328125f,0.1171875f,0.12109375f,0.125f,0.12890625f,0.1328125f,0.13671875f,0.140625f,0.14453125f,0.1484375f,0.15234375f,0.15625f,0.16015625f,0.1640625f,0.16796875f,0.171875f,0.17578125f,0.1796875f,0.18359375f,0.1875f,0.19140625f,0.1953125f,0.19921875f,0.203125f,0.20703125f,0.2109375f,0.21484375f,0.21875f,0.22265625f,0.2265625f,0.23046875f,0.234375f,0.23828125f,0.2421875f,0.24609375f,0.25f,0.2578125f,0.265625f,0.2734375f,0.28125f,0.2890625f,0.296875f,0.3046875f,0.3125f,0.3203125f,0.328125f,0.3359375f,0.34375f,0.3515625f,0.359375f,0.3671875f,0.375f,0.3828125f,0.390625f,0.3984375f,0.40625f,0.4140625f,0.421875f,0.4296875f,0.4375f,0.4453125f,0.453125f,0.4609375f,0.46875f,0.4765625f,0.484375f,0.4921875f,0.5f,0.515625f,0.53125f,0.546875f,0.5625f,0.578125f,0.59375f,0.609375f,0.625f,0.640625f,0.65625f,0.671875f,0.6875f,0.703125f,0.71875f,0.734375f,0.75f,0.765625f,0.78125f,0.796875f,0.8125f,0.828125f,0.84375f,0.859375f,0.875f,0.890625f,0.90625f,0.921875f,0.9375f,0.953125f,0.96875f,0.984375f,1f,1.03125f,1.0625f,1.09375f,1.125f,1.15625f,1.1875f,1.21875f,1.25f,1.28125f,1.3125f,1.34375f,1.375f,1.40625f,1.4375f,1.46875f,1.5f,1.53125f,1.5625f,1.59375f,1.625f,1.65625f,1.6875f,1.71875f,1.75f,1.78125f,1.8125f,1.84375f,1.875f,1.90625f,1.9375f,1.96875f,2f,2.0625f,2.125f,2.1875f,2.25f,2.3125f,2.375f,2.4375f,2.5f,2.5625f,2.625f,2.6875f,2.75f,2.8125f,2.875f,2.9375f,3f,3.0625f,3.125f,3.1875f,3.25f,3.3125f,3.375f,3.4375f,3.5f,3.5625f,3.625f,3.6875f,3.75f,3.8125f,3.875f,3.9375f,4f,4.125f,4.25f,4.375f,4.5f,4.625f,4.75f,4.875f,5f,5.125f,5.25f,5.375f,5.5f,5.625f,5.75f,5.875f,6f,6.125f,6.25f,6.375f,6.5f,6.625f,6.75f,6.875f,7f,7.125f,7.25f,7.375f,7.5f,7.625f,7.75f,7.875f,8f,8.25f,8.5f,8.75f,9f,9.25f,9.5f,9.75f,10f,10.25f,10.5f,10.75f,11f,11.25f,11.5f,11.75f,12f,12.25f,12.5f,12.75f,13f,13.25f,13.5f,13.75f,14f,14.25f,14.5f,14.75f,15f,15.25f,15.5f,15.75f)
|
||||
|
||||
private fun fromFloatToIndex(fval: Float): Int {
|
||||
val (llim, hlim) = binarySearchInterval(fval, LUT)
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.io.OutputStream
|
||||
import java.nio.charset.Charset
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.ceil
|
||||
|
||||
|
||||
@@ -549,7 +550,7 @@ class VM(
|
||||
// println("peek $addr -> ${offset}@${memspace?.javaClass?.canonicalName}")
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -564,7 +565,7 @@ class VM(
|
||||
val (memspace, offset) = translateAddr(addr)
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -583,7 +584,7 @@ class VM(
|
||||
val (memspace, offset) = translateAddr(addr)
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -608,7 +609,7 @@ class VM(
|
||||
val (memspace, offset) = translateAddr(addr)
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -811,7 +812,9 @@ class VM(
|
||||
if (fromRel + len > 1048576) return null
|
||||
|
||||
return if (dev is AudioAdapter) {
|
||||
if (relPtrInDev(fromRel, len, 0, 114687)) dev.sampleBin.ptr + fromRel - 0
|
||||
// Sample-bin window: 0..524287 maps into the 8 MB pool through MMIO 46.
|
||||
if (relPtrInDev(fromRel, len, 0, 524287))
|
||||
dev.sampleBin.ptr + dev.sampleBank.toLong() * AudioAdapter.SAMPLE_BANK_SIZE + fromRel
|
||||
else null
|
||||
}
|
||||
else if (dev is GraphicsAdapter) {
|
||||
@@ -853,3 +856,10 @@ class PeripheralEntry2(
|
||||
)
|
||||
|
||||
internal fun Int.kB() = this * 1024L
|
||||
|
||||
fun Long.memAddrToReadable() = "'${this}' (bank " + this.absoluteValue.minus(if (this < 0) 1 else 0).div(1048576) +
|
||||
" offset " + this.absoluteValue.minus(if (this < 0) 1 else 0).mod(1048576) + ")"
|
||||
|
||||
class OpenBusException(addr: Long) : NullPointerException(
|
||||
"Address ${addr.memAddrToReadable()} is open bus"
|
||||
)
|
||||
@@ -62,7 +62,9 @@ class VMJSR223Delegate(private val vm: VM) {
|
||||
// System.err.println("MEMORY dev=${dev.typestring}, fromIndex=$fromIndex, fromRel=$fromRel")
|
||||
|
||||
return if (dev is AudioAdapter) {
|
||||
if (relPtrInDev(fromRel, len, 0, 114687)) dev.sampleBin.ptr + fromRel - 0
|
||||
// Sample-bin window: 0..524287 maps into the 8 MB pool through MMIO 46.
|
||||
if (relPtrInDev(fromRel, len, 0, 524287))
|
||||
dev.sampleBin.ptr + dev.sampleBank.toLong() * AudioAdapter.SAMPLE_BANK_SIZE + fromRel
|
||||
else null
|
||||
}
|
||||
else if (dev is GraphicsAdapter) {
|
||||
@@ -303,7 +305,6 @@ class VMJSR223Delegate(private val vm: VM) {
|
||||
fun sleep(time: Long) {
|
||||
vm.isIdle.set(true)
|
||||
Thread.sleep(time)
|
||||
Thread.sleep(4L)
|
||||
}
|
||||
|
||||
fun waitForMemChg(addr: Int, andMask: Int, xorMask: Int) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ import java.net.URL
|
||||
*/
|
||||
class HttpModem(private val vm: VM, private val artificialDelayBlockSize: Int = 1024, private val artificialDelayWaitTime: Int = -1) : BlockTransferInterface(false, true) {
|
||||
|
||||
private val DBGPRN = true
|
||||
private val DBGPRN = false
|
||||
|
||||
private fun printdbg(msg: Any) {
|
||||
if (DBGPRN) println("[WgetModem] $msg")
|
||||
|
||||
@@ -3,6 +3,8 @@ package net.torvald.tsvm.peripheral
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.Input
|
||||
import com.badlogic.gdx.InputProcessor
|
||||
import com.badlogic.gdx.math.Vector2
|
||||
import com.badlogic.gdx.utils.viewport.Viewport
|
||||
import net.torvald.AddressOverflowException
|
||||
import net.torvald.DanglingPointerException
|
||||
import net.torvald.UnsafeHelper
|
||||
@@ -10,6 +12,7 @@ import net.torvald.tsvm.CircularArray
|
||||
import net.torvald.tsvm.VM
|
||||
import net.torvald.tsvm.isNonZero
|
||||
import net.torvald.tsvm.toInt
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.experimental.and
|
||||
|
||||
class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
@@ -18,10 +21,25 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
return vm
|
||||
}
|
||||
|
||||
/** Absolute x-position of the computer GUI */
|
||||
var guiPosX = 0
|
||||
/** Absolute y-position of the computer GUI */
|
||||
var guiPosY = 0
|
||||
/**
|
||||
* Viewport that maps screen pixels (as reported by `Gdx.input.x/y`) to the VM's
|
||||
* logical framebuffer coordinate space. The host application owns the rendering
|
||||
* camera, so the host is responsible for installing a viewport whose world
|
||||
* coordinates match the VM framebuffer (origin top-left, world size = framebuffer
|
||||
* size in pixels) and whose screen rectangle matches where the VM is drawn.
|
||||
*
|
||||
* If left null, `Gdx.input.x/y` is forwarded verbatim — only correct when the VM
|
||||
* occupies the entire window at 1:1 scale.
|
||||
*/
|
||||
var inputViewport: Viewport? = null
|
||||
private val tmpMouseVec = Vector2()
|
||||
// Letterbox offset and renderable area inside the inputViewport, set by the host VMGUI.
|
||||
// After unproject, mouse pixel coords are shifted by (inputOriginX, inputOriginY) and
|
||||
// clamped to (inputAreaW, inputAreaH) so apps see VM-screen pixel coords (0..drawWidth).
|
||||
var inputOriginX: Int = 0
|
||||
var inputOriginY: Int = 0
|
||||
var inputAreaW: Int = Int.MAX_VALUE
|
||||
var inputAreaH: Int = Int.MAX_VALUE
|
||||
|
||||
/** Accepts a keycode */
|
||||
private val keyboardBuffer = CircularArray<Byte>(32, true)
|
||||
@@ -98,7 +116,12 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
in 0..31 -> keyboardBuffer[(addr.toInt())] ?: -1
|
||||
in 32..33 -> (mouseX.toInt() shr (adi - 32).times(8)).toByte()
|
||||
in 34..35 -> (mouseY.toInt() shr (adi - 34).times(8)).toByte()
|
||||
36L -> mouseDown.toInt().toByte()
|
||||
36L -> {
|
||||
// bit 0: left, bit 1: right, bit 2: middle, bit 6: wheel up, bit 7: wheel down
|
||||
// Wheel bits are latched on scrolled() and cleared on read so a one-shot
|
||||
// detent fires exactly once for the polling app.
|
||||
(mouseButtons or wheelLatch.getAndSet(0)).toByte()
|
||||
}
|
||||
37L -> {
|
||||
val key = keyboardBuffer.removeTail() ?: -1
|
||||
keyPushed = !keyboardBuffer.isEmpty // Clear flag when buffer becomes empty
|
||||
@@ -280,7 +303,9 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
|
||||
private var mouseX: Short = 0
|
||||
private var mouseY: Short = 0
|
||||
private var mouseDown = false
|
||||
private var mouseButtons: Int = 0 // bit 0 = LEFT, bit 1 = RIGHT, bit 2 = MIDDLE
|
||||
// bits 6 (wheel up) and 7 (wheel down) — set by scrolled(), cleared on MMIO[36] read
|
||||
private val wheelLatch = AtomicInteger(0)
|
||||
private var systemUptime = 0L
|
||||
private var rtc = 0L
|
||||
|
||||
@@ -296,10 +321,28 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
keyEventBuffers.fill(0)
|
||||
|
||||
if (isFocused) {
|
||||
// store mouse info
|
||||
mouseX = (Gdx.input.x + guiPosX).toShort()
|
||||
mouseY = (Gdx.input.y + guiPosY).toShort()
|
||||
mouseDown = Gdx.input.isTouched
|
||||
// store mouse info; unproject through the host-provided viewport so the
|
||||
// VM sees logical framebuffer pixels regardless of window magnification,
|
||||
// letterboxing or sub-region placement done by an embedding GDX app.
|
||||
val vp = inputViewport
|
||||
val rawX: Int
|
||||
val rawY: Int
|
||||
if (vp != null) {
|
||||
tmpMouseVec.set(Gdx.input.x.toFloat(), Gdx.input.y.toFloat())
|
||||
vp.unproject(tmpMouseVec)
|
||||
rawX = tmpMouseVec.x.toInt()
|
||||
rawY = tmpMouseVec.y.toInt()
|
||||
}
|
||||
else {
|
||||
rawX = Gdx.input.x
|
||||
rawY = Gdx.input.y
|
||||
}
|
||||
// Subtract the letterbox origin so apps see VM-screen pixel coords (0..drawWidth).
|
||||
mouseX = (rawX - inputOriginX).coerceIn(0, inputAreaW - 1).toShort()
|
||||
mouseY = (rawY - inputOriginY).coerceIn(0, inputAreaH - 1).toShort()
|
||||
mouseButtons = (if (Gdx.input.isButtonPressed(Input.Buttons.LEFT)) 1 else 0) or
|
||||
(if (Gdx.input.isButtonPressed(Input.Buttons.RIGHT)) 2 else 0) or
|
||||
(if (Gdx.input.isButtonPressed(Input.Buttons.MIDDLE)) 4 else 0)
|
||||
|
||||
// strobe keys to fill the key read buffer
|
||||
var keysPushed = 0
|
||||
@@ -313,7 +356,7 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
}
|
||||
}
|
||||
else {
|
||||
mouseDown = false
|
||||
mouseButtons = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,8 +419,15 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
override fun scrolled(p0: Float, p1: Float): Boolean {
|
||||
return false
|
||||
override fun scrolled(amountX: Float, amountY: Float): Boolean {
|
||||
// LibGDX: amountY > 0 = scroll DOWN (toward user), amountY < 0 = scroll UP.
|
||||
// Latch bits 6/7 of MMIO[36]; the latch is cleared the next time MMIO[36] is read.
|
||||
if (Gdx.input.inputProcessor !== this) return false
|
||||
when {
|
||||
amountY < 0f -> wheelLatch.updateAndGet { it or 0x40 }
|
||||
amountY > 0f -> wheelLatch.updateAndGet { it or 0x80 }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun keyUp(p0: Int): Boolean {
|
||||
|
||||
@@ -43,6 +43,7 @@ package net.torvald.tsvm.peripheral
|
||||
|
||||
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
|
||||
import net.torvald.tsvm.VM
|
||||
import net.torvald.tsvm.memAddrToReadable
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.math.ceil
|
||||
@@ -398,7 +399,7 @@ class MP2Env(val vm: VM) {
|
||||
};
|
||||
// check for valid header: syncword OK, MPEG-Audio Layer 2
|
||||
if ((syspeek(mp2_frame!!) != 0xFF) || ((syspeek(mp2_frame!! + 1*incr) and 0xFE) != 0xFC)){
|
||||
throw Error("Invalid MP2 header at $mp2_frame: ${syspeek(mp2_frame!!).toString(16)} ${syspeek(mp2_frame!! + 1*incr).toString(16)}")
|
||||
throw Error("Invalid MP2 header at ${(mp2_frame as Long).memAddrToReadable()}: ${syspeek(mp2_frame!!).toString(16)} ${syspeek(mp2_frame!! + 1*incr).toString(16)}")
|
||||
};
|
||||
|
||||
// set up the bitstream reader
|
||||
|
||||
@@ -561,7 +561,10 @@ class TestDiskDrive(private val vm: VM, private val driveNum: Int, theRootPath:
|
||||
statusCode.set(STATE_CODE_STANDBY)
|
||||
}
|
||||
else if (inputString.startsWith("USAGE")) {
|
||||
recipient?.writeout(composePositiveAns("USED123456/TOTAL654321"))
|
||||
val used = rootPath.walkTopDown().filter { it.isFile }.map { it.length() }.sum()
|
||||
.coerceIn(0L, Int.MAX_VALUE.toLong())
|
||||
val total = rootPath.totalSpace.coerceIn(0L, Int.MAX_VALUE.toLong())
|
||||
recipient?.writeout(composePositiveAns("USED$used/TOTAL$total"))
|
||||
statusCode.set(STATE_CODE_STANDBY)
|
||||
}
|
||||
else
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -12,5 +12,7 @@
|
||||
<orderEntry type="library" name="jetbrains.kotlin.reflect" level="project" />
|
||||
<orderEntry type="library" name="jetbrains.kotlin.test" level="project" />
|
||||
<orderEntry type="library" name="lib" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx.backend.lwjgl3" level="project" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -10,5 +10,7 @@
|
||||
<orderEntry type="library" name="TerranVirtualDisk" level="project" />
|
||||
<orderEntry type="module" module-name="tsvm_core" />
|
||||
<orderEntry type="library" name="lib" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx.backend.lwjgl3" level="project" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -54,8 +54,8 @@ public class AppLoader {
|
||||
|
||||
|
||||
ArrayList defaultPeripherals = new ArrayList();
|
||||
defaultPeripherals.add(new Pair(3, new PeripheralEntry2("net.torvald.tsvm.peripheral.AudioAdapter", vm)));
|
||||
defaultPeripherals.add(new Pair(4, new PeripheralEntry2("net.torvald.tsvm.peripheral.HostFileHSDPA", vm, "assets/diskMediabin/lnterz_013.mv2", "assets/diskMediabin/ba60d.mov", "", "", 999999999L)));
|
||||
defaultPeripherals.add(new Pair(2, new PeripheralEntry2("net.torvald.tsvm.peripheral.AudioAdapter", vm)));
|
||||
defaultPeripherals.add(new Pair(3, new PeripheralEntry2("net.torvald.tsvm.peripheral.HostFileHSDPA", vm, "assets/diskMediabin/lnterz_013.mv2", "assets/diskMediabin/ba60d.mov", "", "", 999999999L)));
|
||||
|
||||
|
||||
EmulInstance reference = new EmulInstance(vm, "net.torvald.tsvm.peripheral.ReferenceGraphicsAdapter", diskPath, 560, 448, defaultPeripherals);
|
||||
|
||||
@@ -8,7 +8,10 @@ import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch
|
||||
import net.torvald.reflection.extortField
|
||||
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE2
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE3
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT2
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL
|
||||
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
|
||||
@@ -18,6 +21,7 @@ import java.util.BitSet
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -25,9 +29,25 @@ import kotlin.math.roundToInt
|
||||
*/
|
||||
class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMenu(parent, x, y, w, h) {
|
||||
|
||||
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), 3=cuesheet detail
|
||||
private val scopeMode = IntArray(4)
|
||||
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub),
|
||||
// 3=cuesheet detail, 4=per-voice waveform
|
||||
private val scopeMode = IntArray(4) { 4 }
|
||||
private val scopeScrollHorz = IntArray(4)
|
||||
private val SCOPE_MODE_COUNT = 5
|
||||
|
||||
// Which playhead the big scope is showing. Status-panel clicks change this.
|
||||
private var selectedPlayhead = 0
|
||||
|
||||
// Layout — one big scope on top, four status panels along the bottom.
|
||||
private val bigScopeX = 7
|
||||
private val bigScopeY = 5
|
||||
private val bigScopeW = 622
|
||||
private val bigScopeH = 336
|
||||
private val statusW = 102
|
||||
private val statusH = 8 * FONT.H + 4
|
||||
private val statusY = bigScopeY + bigScopeH + 4
|
||||
// Spread the four status panels evenly across the big-scope width.
|
||||
private fun statusX(i: Int): Int = bigScopeX + i * (bigScopeW - statusW) / 3
|
||||
|
||||
override fun show() {
|
||||
}
|
||||
@@ -38,96 +58,71 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
private var guiClickLatched = arrayOf(false, false, false, false, false, false, false, false)
|
||||
private var guiKeypressLatched = BitSet(256)
|
||||
|
||||
private fun panelAtMouse(mx: Int, my: Int): Int {
|
||||
if (my !in statusY until (statusY + statusH)) return -1
|
||||
for (i in 0..3) {
|
||||
val sx = statusX(i)
|
||||
if (mx in sx until (sx + statusW)) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun mouseInBigScope(mx: Int, my: Int): Boolean =
|
||||
mx in bigScopeX until (bigScopeX + bigScopeW) &&
|
||||
my in bigScopeY until (bigScopeY + bigScopeH)
|
||||
|
||||
override fun update() {
|
||||
// mouse clicks
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
// ── LEFT click ─────────────────────────────────────────────────────────────
|
||||
// On a status panel: select that playhead as the big-scope target.
|
||||
// On the big scope: cycle scope mode forward for the selected playhead.
|
||||
if (Gdx.input.isButtonPressed(Buttons.LEFT)) {
|
||||
if (!guiClickLatched[Buttons.LEFT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeMode[3 - i] = (scopeMode[3 - i] + 1) and 3
|
||||
break
|
||||
}
|
||||
}
|
||||
val panel = panelAtMouse(mx, my)
|
||||
if (panel >= 0) {
|
||||
selectedPlayhead = panel
|
||||
} else if (mouseInBigScope(mx, my)) {
|
||||
scopeMode[selectedPlayhead] =
|
||||
(scopeMode[selectedPlayhead] + 1) % SCOPE_MODE_COUNT
|
||||
}
|
||||
|
||||
guiClickLatched[Buttons.LEFT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
guiClickLatched[Buttons.LEFT] = false
|
||||
}
|
||||
|
||||
// ── RIGHT click on the big scope: cycle scope mode backward. ────────────────
|
||||
if (Gdx.input.isButtonPressed(Buttons.RIGHT)) {
|
||||
if (!guiClickLatched[Buttons.RIGHT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeMode[3 - i] = (scopeMode[3 - i] - 1) and 3
|
||||
break
|
||||
}
|
||||
}
|
||||
if (mouseInBigScope(mx, my)) {
|
||||
scopeMode[selectedPlayhead] =
|
||||
(scopeMode[selectedPlayhead] + SCOPE_MODE_COUNT - 1) % SCOPE_MODE_COUNT
|
||||
}
|
||||
|
||||
guiClickLatched[Buttons.RIGHT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
guiClickLatched[Buttons.RIGHT] = false
|
||||
}
|
||||
|
||||
// keyboard left/right
|
||||
// ── Keyboard left/right: scroll the selected playhead's pattern view. ───────
|
||||
if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
|
||||
if (!guiKeypressLatched[Input.Keys.LEFT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] - 1).coerceIn(0, 14)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopeScrollHorz[selectedPlayhead] =
|
||||
(scopeScrollHorz[selectedPlayhead] - 1).coerceIn(0, 14)
|
||||
guiKeypressLatched[Input.Keys.LEFT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
guiKeypressLatched[Input.Keys.LEFT] = false
|
||||
}
|
||||
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
|
||||
if (!guiKeypressLatched[Input.Keys.RIGHT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] + 1).coerceIn(0, 14)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopeScrollHorz[selectedPlayhead] =
|
||||
(scopeScrollHorz[selectedPlayhead] + 1).coerceIn(0, 14)
|
||||
guiKeypressLatched[Input.Keys.RIGHT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
guiKeypressLatched[Input.Keys.RIGHT] = false
|
||||
}
|
||||
}
|
||||
@@ -167,27 +162,32 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
val adev = parent.currentlyPersistentVM?.vm?.peripheralTable?.getOrNull(cardIndex ?: -1)?.peripheral as? AudioAdapter
|
||||
|
||||
if (adev != null) {
|
||||
val playheads = adev.extortField<Array<AudioAdapter.Playhead>>("playheads")!!
|
||||
|
||||
// draw status LCD
|
||||
// ── Big scope background (row 1) and status-panel backgrounds (row 2) ─────
|
||||
batch.inUse {
|
||||
// draw backgrounds
|
||||
batch.color = COL_WELL
|
||||
for (i in 0..3) { batch.fillRect(7, 5 + 115*i, 102, 8*FONT.H + 4) }
|
||||
}
|
||||
for (i in 0..3) {
|
||||
val ahead = adev.extortField<Array<AudioAdapter.Playhead>>("playheads")!![i]
|
||||
drawStatusLCD(adev, ahead, batch, i, 9f + 7, 7f + 7 + 115 * i)
|
||||
}
|
||||
|
||||
// draw Soundscope like this so that the overflown queue sparkline would not be overlaid on top of the envelopes
|
||||
batch.inUse {
|
||||
// draw backgrounds
|
||||
batch.color = COL_SOUNDSCOPE_BACK
|
||||
for (i in 0..3) { batch.fillRect(117, 5 + 115*i, 512, 8*FONT.H + 4) }
|
||||
batch.fillRect(bigScopeX, bigScopeY, bigScopeW, bigScopeH)
|
||||
|
||||
// Highlight border behind the selected status panel.
|
||||
batch.color = COL_ACTIVE
|
||||
val selX = statusX(selectedPlayhead)
|
||||
batch.fillRect(selX - 2, statusY - 2, statusW + 4, statusH + 4)
|
||||
|
||||
batch.color = COL_WELL
|
||||
for (i in 0..3) batch.fillRect(statusX(i), statusY, statusW, statusH)
|
||||
}
|
||||
|
||||
// ── Big scope contents — only the selected playhead ────────────────────────
|
||||
drawSoundscope(adev, playheads[selectedPlayhead], batch, selectedPlayhead,
|
||||
bigScopeX.toFloat(), bigScopeY.toFloat(), bigScopeW, bigScopeH)
|
||||
|
||||
// ── All four status LCDs along the bottom ──────────────────────────────────
|
||||
// Use the same (9, 9) inset from the panel as the original layout, so the
|
||||
// existing label-positioning math inside drawStatusLCD still fits cleanly.
|
||||
for (i in 0..3) {
|
||||
val ahead = adev.extortField<Array<AudioAdapter.Playhead>>("playheads")!![i]
|
||||
drawSoundscope(adev, ahead, batch, i, 117f, 5f + 115 * i)
|
||||
drawStatusLCD(adev, playheads[i], batch, i,
|
||||
statusX(i).toFloat() + 9f, statusY.toFloat() + 9f)
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -203,11 +203,16 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
// NOTE: Samples count for PCM mode is drawn by drawSoundscope() function, not this one!
|
||||
|
||||
batch.inUse {
|
||||
// "P{n+1}" tag — bright on the selected playhead so the panel-as-button
|
||||
// affordance is obvious.
|
||||
batch.color = if (index == selectedPlayhead) COL_ACTIVE else Color.WHITE
|
||||
FONT.draw(batch, "P${index + 1}", x, y)
|
||||
|
||||
batch.color = Color.WHITE
|
||||
// PLAY icon
|
||||
// PLAY icon (shifted right to make room for the playhead tag)
|
||||
if (ahead.isPlaying)
|
||||
FONT.draw(batch, STR_PLAY, x, y)
|
||||
FONT.draw(batch, if (ahead.isPcmMode) "PCM" else "TRACKER", x + 21, y)
|
||||
FONT.draw(batch, STR_PLAY, x + 21, y)
|
||||
FONT.draw(batch, if (ahead.isPcmMode) "PCM" else "TRACKER", x + 42, y)
|
||||
|
||||
// PCM Mode labels
|
||||
if (ahead.isPcmMode) {
|
||||
@@ -238,7 +243,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
FONT.draw(batch, "Tickrate", x, y + 6*FONT.H)
|
||||
|
||||
batch.color = COL_ACTIVE3
|
||||
FONT.drawRalign(batch, "${ahead.trackerState?.cuePos?.toString(16)?.uppercase()?.padStart(2,'0')}:${ahead.trackerState?.rowIndex?.toString()?.uppercase()?.padStart(2,'0')}", x + 84, y + 2*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.trackerState?.cuePos?.toString(16)?.uppercase()?.padStart(3,'0')}:${ahead.trackerState?.rowIndex?.toString()?.uppercase()?.padStart(2,'0')}", x + 84, y + 2*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.masterVolume}", x + 84, y + 3*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.masterPan}", x + 84, y + 4*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.bpm}", x + 84, y + 5*FONT.H)
|
||||
@@ -261,58 +266,125 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt()
|
||||
private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt()
|
||||
|
||||
private val VOX_PER_VIEW = arrayOf(6,20,20)
|
||||
/**
|
||||
* Find the most-recent rising-edge zero crossing in [buf] that has at least
|
||||
* [cellW]/2 samples of context on either side, and return its position as a
|
||||
* sub-sample-accurate "age" (samples since the oldest sample at [writePos]).
|
||||
* Returns -1.0 if no usable crossing exists — the caller should then fall back
|
||||
* to a free-running display.
|
||||
*/
|
||||
private fun findTriggerAge(buf: FloatArray, writePos: Int, cellW: Int): Double {
|
||||
val bufSize = buf.size
|
||||
val mask = bufSize - 1
|
||||
val halfW = cellW / 2
|
||||
val maxAge = bufSize - halfW // exclusive: rightmost trigger that still has cellW/2 right-side samples
|
||||
val minAge = halfW // inclusive: leftmost trigger that still has cellW/2 left-side samples
|
||||
if (maxAge - 1 <= minAge) return -1.0 // cell is too wide vs the buffer
|
||||
|
||||
// Walk newest → oldest within the search window. The most-recent crossing gives
|
||||
// the freshest snapshot on the right of the trigger, so the eye sees the least lag.
|
||||
var newer = buf[(writePos + maxAge - 1) and mask]
|
||||
for (age in maxAge - 2 downTo minAge) {
|
||||
val older = buf[(writePos + age) and mask]
|
||||
if (older < 0f && newer >= 0f) {
|
||||
// Linear interpolation between the two bracketing samples.
|
||||
val denom = (newer - older)
|
||||
val frac = if (denom > 1e-9f) (-older) / denom else 0f
|
||||
return age + frac.toDouble()
|
||||
}
|
||||
newer = older
|
||||
}
|
||||
return -1.0
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a cols × rows grid for `n` waveform cells inside an `areaW × areaH` box.
|
||||
* Optimises for cell aspect close to [targetAspect] (in log-space, so 6:1 and 1.5:1
|
||||
* are penalised equally relative to 3:1) and lightly penalises wasted cells. Wide
|
||||
* scope areas naturally get more columns than rows; tall ones flip the other way.
|
||||
*/
|
||||
private fun pickWaveformGrid(n: Int, areaW: Int, areaH: Int): IntArray {
|
||||
val targetAspect = 3.0
|
||||
val wastePenalty = 0.3
|
||||
var bestCols = 1
|
||||
var bestRows = n
|
||||
var bestScore = Double.POSITIVE_INFINITY
|
||||
for (cols in 1..n) {
|
||||
val rows = (n + cols - 1) / cols
|
||||
val cellW = areaW.toDouble() / cols
|
||||
val cellH = areaH.toDouble() / rows
|
||||
val aspect = cellW / cellH
|
||||
val score = abs(ln(aspect / targetAspect)) + wastePenalty * (cols * rows - n)
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
bestCols = cols
|
||||
bestRows = rows
|
||||
}
|
||||
}
|
||||
return intArrayOf(bestCols, bestRows)
|
||||
}
|
||||
|
||||
private val VOX_PER_VIEW = arrayOf(10,20,20)
|
||||
private val VOL_SYM = arrayOf('@','^','&',' ')
|
||||
private val PAN_SYM = arrayOf('@','<','>',' ')
|
||||
|
||||
private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float) {
|
||||
private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float, w: Int, h: Int) {
|
||||
val gdxadev = ahead.audioDevice
|
||||
val bytes = gdxadev.extortField<ByteArray>("bytes")
|
||||
val bytesLen = gdxadev.extortField<Int>("bytesLength")!!
|
||||
val envelopeHalfHeight = 27
|
||||
val envelopeHalfHeight = h / 4
|
||||
val lCenterY = h / 4
|
||||
val rCenterY = 3 * h / 4
|
||||
val patOffY = 0
|
||||
|
||||
batch.inUse {
|
||||
if (ahead.isPcmMode && bytes != null) {
|
||||
val smpCnt = bytesLen / 4 - 1
|
||||
|
||||
for (s in 0..511) {
|
||||
val i = (smpCnt * (s / 511.0)).roundToInt().and(0xfffffe)
|
||||
try {
|
||||
for (s in 0 until w) {
|
||||
val i = (smpCnt * (s / (w - 1).toDouble())).roundToInt().and(0xfffffe)
|
||||
|
||||
val smpL = (bytes[i*4].toUint() or bytes[i*4+1].toUint().shl(8)).u16Tos16().toDouble().div(32767)
|
||||
val smpR = (bytes[i*4+2].toUint() or bytes[i*4+3].toUint().shl(8)).u16Tos16().toDouble().div(32767)
|
||||
val smpL =
|
||||
(bytes[i * 4].toUint() or bytes[i * 4 + 1].toUint().shl(8)).u16Tos16().toDouble().div(32767)
|
||||
val smpR = (bytes[i * 4 + 2].toUint() or bytes[i * 4 + 3].toUint().shl(8)).u16Tos16().toDouble()
|
||||
.div(32767)
|
||||
|
||||
val smpLH = smpL * envelopeHalfHeight
|
||||
val smpRH = smpR * envelopeHalfHeight
|
||||
val smpLH = smpL * envelopeHalfHeight
|
||||
val smpRH = smpR * envelopeHalfHeight
|
||||
|
||||
val smpLHi = bipolarFloor(smpLH)
|
||||
val smpRHi = bipolarFloor(smpRH)
|
||||
val smpLHi2 = bipolarCeil(smpLH)
|
||||
val smpRHi2 = bipolarCeil(smpRH)
|
||||
val smpLHi = bipolarFloor(smpLH)
|
||||
val smpRHi = bipolarFloor(smpRH)
|
||||
val smpLHi2 = bipolarCeil(smpLH)
|
||||
val smpRHi2 = bipolarCeil(smpRH)
|
||||
|
||||
val smpLHe = abs(smpLH - smpLHi).toFloat()
|
||||
val smpRHe = abs(smpRH - smpRHi).toFloat()
|
||||
val smpLHe = abs(smpLH - smpLHi).toFloat()
|
||||
val smpRHe = abs(smpRH - smpRHi).toFloat()
|
||||
|
||||
// antialias in y-axis
|
||||
if (smpLHi != smpLHi2) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpLHe)
|
||||
batch.fillRect(x + s, y + 27, 1, smpLHi2)
|
||||
}
|
||||
if (smpRHi != smpRHi2) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpRHe)
|
||||
batch.fillRect(x + s, y + 81, 1, smpRHi2)
|
||||
// antialias in y-axis
|
||||
if (smpLHi != smpLHi2) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpLHe)
|
||||
batch.fillRect(x + s, y + lCenterY, 1, smpLHi2)
|
||||
}
|
||||
if (smpRHi != smpRHi2) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpRHe)
|
||||
batch.fillRect(x + s, y + rCenterY, 1, smpRHi2)
|
||||
}
|
||||
|
||||
// base texture
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
batch.fillRect(x + s, y + lCenterY, 1, smpLHi)
|
||||
batch.fillRect(x + s, y + rCenterY, 1, smpRHi)
|
||||
}
|
||||
|
||||
// base texture
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
batch.fillRect(x + s, y + 27, 1, smpLHi)
|
||||
batch.fillRect(x + s, y + 81, 1, smpRHi)
|
||||
// PCM Samples count — drawn inside the scope (top-left) since the status
|
||||
// panels no longer sit beside it in the new single-scope layout.
|
||||
batch.color = Color.WHITE
|
||||
FONT.draw(batch, "Samples", x + 4, y + patOffY)
|
||||
batch.color = COL_ACTIVE3
|
||||
FONT.draw(batch, "${smpCnt + 1}", x + 4 + 8 * FONT.W, y + patOffY)
|
||||
}
|
||||
|
||||
batch.color = Color.WHITE
|
||||
FONT.draw(batch, "Samples", x - 101, y + 5*FONT.H + 9)
|
||||
batch.color = COL_ACTIVE3
|
||||
FONT.drawRalign(batch, "${smpCnt+1}", x - 17, y + 5*FONT.H + 9)
|
||||
|
||||
catch (_: ArrayIndexOutOfBoundsException) {}
|
||||
}
|
||||
else {
|
||||
// Tracker pattern visualiser.
|
||||
@@ -320,11 +392,13 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
val ts = ahead.trackerState
|
||||
if (ts == null) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
FONT.draw(batch, "No tracker state", x, y + 4)
|
||||
FONT.draw(batch, "No tracker state", x, y + patOffY)
|
||||
} else {
|
||||
val cuePos = ts.cuePos
|
||||
val rowIdx = ts.rowIndex
|
||||
val ROWS = 17
|
||||
// Rows scale with available height — the original 17-row layout was sized
|
||||
// for the old 108-pixel scope; the big scope can show many more rows.
|
||||
val ROWS = (h / TINY.H).coerceAtLeast(1)
|
||||
val PTN_MAX_ROWS = 63
|
||||
|
||||
when (scopeMode[index]) {
|
||||
@@ -338,11 +412,11 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
val ci = cueFirst + r
|
||||
if (ci > 1023) break
|
||||
val here = ci == cuePos
|
||||
val ry = y + 4 + r * TINY.H
|
||||
val ry = y + patOffY + r * TINY.H
|
||||
|
||||
if (here) {
|
||||
batch.color = COL_TRACKER_ROW
|
||||
batch.fillRect(x, ry, 512, TINY.H)
|
||||
batch.fillRect(x, ry, w, TINY.H)
|
||||
}
|
||||
|
||||
var cx = x
|
||||
@@ -376,6 +450,91 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode 4: Per-voice waveform ───────────────────────────────────
|
||||
// Tile one waveform cell per "currently used" voice (cue-sheet
|
||||
// pattern number != 0xFFF). The soundscope area is wide and short,
|
||||
// so a cols × rows grid uses the space far better than a vertical
|
||||
// stack — pickWaveformGrid() picks a layout that keeps cells roughly
|
||||
// 3:1 wide while minimising empty slots.
|
||||
4 -> {
|
||||
val cuePats = IntArray(20) { vi -> readCuePat12(audio, cuePos, vi) }
|
||||
val activeVoiceIndices = (0 until 20).filter { cuePats[it] != 0xFFF }
|
||||
if (activeVoiceIndices.isEmpty()) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
FONT.draw(batch, "No active voices", x, y + 4)
|
||||
} else {
|
||||
val scopeH = h
|
||||
val scopeW = w
|
||||
val n = activeVoiceIndices.size
|
||||
val grid = pickWaveformGrid(n, scopeW, scopeH)
|
||||
val cols = grid[0]
|
||||
val rows = grid[1]
|
||||
val cellW = scopeW / cols
|
||||
val cellH = scopeH / rows
|
||||
val halfH = ((cellH - 2) / 2).coerceAtLeast(1)
|
||||
val voices = ts.voices
|
||||
val drawLabel = cellH >= TINY.H + 1 && cellW >= 12
|
||||
|
||||
// Faint grid separators between cells.
|
||||
batch.color = COL_TRACKER_ROW
|
||||
for (r in 1 until rows) batch.fillRect(x, y + r * cellH, scopeW, 1)
|
||||
for (c in 1 until cols) batch.fillRect(x + c * cellW, y, 1, scopeH)
|
||||
|
||||
for ((slot, vi) in activeVoiceIndices.withIndex()) {
|
||||
val voice = voices.getOrNull(vi) ?: continue
|
||||
val col = slot % cols
|
||||
val row = slot / cols
|
||||
val cellX = x + col * cellW
|
||||
val cellY = y + row * cellH
|
||||
val centerY = cellY + cellH / 2
|
||||
|
||||
// baseline
|
||||
batch.color = COL_TRACKER_ROW
|
||||
batch.fillRect(cellX, centerY, cellW, 1)
|
||||
|
||||
// waveform — anchor the cell centre on the most recent
|
||||
// sub-sample-accurate rising-edge zero crossing so that
|
||||
// periodic signals appear stationary (oscilloscope trigger).
|
||||
// Falls back to a free-running, oldest→newest sweep when no
|
||||
// usable trigger is found (e.g. silent voice or sub-sub-Hz tone).
|
||||
batch.color = COL_VOICE_PALETTE[vi % COL_VOICE_PALETTE.size]
|
||||
val buf = voice.scopeBuffer
|
||||
val bufSize = buf.size
|
||||
val mask = bufSize - 1
|
||||
val writePos = voice.scopeWritePos
|
||||
val centerCol = cellW / 2
|
||||
val triggerAge = findTriggerAge(buf, writePos, cellW)
|
||||
val freeRunStep = (bufSize - 1).toDouble() / (cellW - 1).coerceAtLeast(1)
|
||||
for (sx in 0 until cellW) {
|
||||
val readAge = if (triggerAge >= 0.0)
|
||||
triggerAge + (sx - centerCol).toDouble()
|
||||
else
|
||||
sx * freeRunStep
|
||||
val baseAge = floor(readAge).toInt()
|
||||
val frac = (readAge - baseAge).toFloat()
|
||||
val a = buf[(writePos + baseAge) and mask]
|
||||
val b = buf[(writePos + baseAge + 1) and mask]
|
||||
val v = ((1f - frac) * a + frac * b).coerceIn(-1f, 1f)
|
||||
val h = (v * halfH).roundToInt()
|
||||
if (h == 0) {
|
||||
batch.fillRect(cellX + sx, centerY, 1, 1)
|
||||
} else if (h > 0) {
|
||||
batch.fillRect(cellX + sx, centerY, 1, h)
|
||||
} else {
|
||||
batch.fillRect(cellX + sx, centerY + h, 1, -h)
|
||||
}
|
||||
}
|
||||
|
||||
// voice index label (top-left of cell), only when there is room
|
||||
if (drawLabel) {
|
||||
batch.color = COL_VOICE_PALETTE[vi % COL_VOICE_PALETTE.size]
|
||||
TINY.draw(batch, (vi+1).toString().padStart(2, '0').uppercase(),
|
||||
cellX + 1, cellY + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode 0: Detailed pattern with colour-coded fields ────────────
|
||||
// ── Mode 1: Abridged pattern with colour-coded fields ────────────
|
||||
// ── Mode 2: Super-abridged pattern with colour-coded fields ────────────
|
||||
@@ -395,12 +554,12 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch,
|
||||
"${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}",
|
||||
x, y + 4 + r * TINY.H)
|
||||
x, y + patOffY + r * TINY.H)
|
||||
}
|
||||
|
||||
// Vertical separator
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
for (r in 0 until ROWS) TINY.draw(batch, "|", x + cueW, y + 4 + r * TINY.H)
|
||||
for (r in 0 until ROWS) TINY.draw(batch, "|", x + cueW, y + patOffY + r * TINY.H)
|
||||
*/
|
||||
|
||||
// Pattern index for each voice in current cue
|
||||
@@ -414,11 +573,11 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
val ri = rowFirst + r
|
||||
if (ri > PTN_MAX_ROWS) break
|
||||
val here = ri == rowIdx
|
||||
val ry = y + 4 + r * TINY.H
|
||||
val ry = y + patOffY + r * TINY.H
|
||||
|
||||
if (here) {
|
||||
batch.color = COL_TRACKER_ROW
|
||||
batch.fillRect(patX, ry, 512 - cueW - sepW, TINY.H)
|
||||
batch.fillRect(patX, ry, w - cueW - sepW, TINY.H)
|
||||
}
|
||||
|
||||
var cx = patX
|
||||
|
||||
@@ -127,7 +127,9 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
internal fun moveView(oldIndex: Int, newIndex: Int?) {
|
||||
if (oldIndex != newIndex) {
|
||||
if (newIndex != null) {
|
||||
vms[newIndex] = vms[oldIndex]
|
||||
val moved = vms[oldIndex]
|
||||
vms[newIndex] = moved
|
||||
moved?.vm?.let { applyMouseInputMappingForPanel(it, newIndex) }
|
||||
}
|
||||
vms[oldIndex] = null
|
||||
}
|
||||
@@ -135,6 +137,28 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
|
||||
internal fun addVMtoView(vm: VM, profileName: String, index: Int) {
|
||||
vms[index] = VMRunnerInfo(vm, profileName)
|
||||
applyMouseInputMappingForPanel(vm, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the VM's IOSpace so the mouse pixels it sees are relative to its own
|
||||
* GPU framebuffer rather than the whole TsvmEmulator window. Each tiled VM
|
||||
* lives at panel (pposX, pposY) with a letterbox inside that panel, so the
|
||||
* offset is `panel origin + (panel size − GPU size) / 2`.
|
||||
*/
|
||||
private fun applyMouseInputMappingForPanel(vm: VM, panelIndex: Int) {
|
||||
val gpu = vm.peripheralTable.getOrNull(1)?.peripheral as? GraphicsAdapter ?: return
|
||||
val pposX = panelIndex % panelsX
|
||||
val pposY = panelIndex / panelsX
|
||||
val gpuW = gpu.config.width
|
||||
val gpuH = gpu.config.height
|
||||
val io = vm.getIO()
|
||||
// TsvmEmulator draws at 1:1 pixel scale, so no GDX viewport is needed.
|
||||
io.inputViewport = null
|
||||
io.inputOriginX = pposX * windowWidth + (windowWidth - gpuW) / 2
|
||||
io.inputOriginY = pposY * windowHeight + (windowHeight - gpuH) / 2
|
||||
io.inputAreaW = gpuW
|
||||
io.inputAreaH = gpuH
|
||||
}
|
||||
|
||||
internal fun getCurrentlySelectedVM(): VMRunnerInfo? = if (currentVMselection == null) null else vms[currentVMselection!!]
|
||||
@@ -201,6 +225,7 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
val vm1 = getVMbyProfileName("Initial VM")!!
|
||||
initVMenv(vm1, "Initial VM")
|
||||
vms[0] = VMRunnerInfo(vm1, "Initial VM")
|
||||
applyMouseInputMappingForPanel(vm1, 0)
|
||||
|
||||
init()
|
||||
}
|
||||
@@ -307,6 +332,11 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
if (currentVMselection != null && vms[currentVMselection!!]?.vm?.id == vm.id) {
|
||||
Gdx.input.inputProcessor = vm.getIO()
|
||||
}
|
||||
|
||||
// peripheralTable[1] (the GPU) was disposed and re-installed; re-apply
|
||||
// the mouse mapping so the rebooted VM keeps targeting its own panel.
|
||||
val panelIndex = vms.indexOfFirst { it?.vm?.id == vm.id }
|
||||
if (panelIndex >= 0) applyMouseInputMappingForPanel(vm, panelIndex)
|
||||
}
|
||||
|
||||
private fun updateGame(delta: Float) {
|
||||
@@ -434,6 +464,10 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
this.panelsX = panelsX
|
||||
this.panelsY = panelsY
|
||||
resize(windowWidth * panelsX, windowHeight * panelsY)
|
||||
// Panel positions shifted, so every VM needs its mouse origin re-mapped.
|
||||
vms.forEachIndexed { index, info ->
|
||||
info?.vm?.let { applyMouseInputMappingForPanel(it, index) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun resize(width: Int, height: Int) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.badlogic.gdx.graphics.g2d.SpriteBatch
|
||||
import com.badlogic.gdx.graphics.g2d.TextureRegion
|
||||
import com.badlogic.gdx.graphics.glutils.FrameBuffer
|
||||
import com.badlogic.gdx.graphics.glutils.ShaderProgram
|
||||
import com.badlogic.gdx.utils.viewport.StretchViewport
|
||||
import com.badlogic.gdx.utils.viewport.Viewport
|
||||
import net.torvald.terrarum.DefaultGL32Shaders
|
||||
import net.torvald.tsvm.peripheral.*
|
||||
import net.torvald.tsvm.peripheral.GraphicsAdapter.Companion.DRAW_SHADER_VERT
|
||||
@@ -48,6 +50,14 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
lateinit var batch: SpriteBatch
|
||||
lateinit var camera: OrthographicCamera
|
||||
|
||||
/**
|
||||
* Maps window pixels to the VM framebuffer (origin top-left, world size =
|
||||
* viewportWidth × viewportHeight). Stretches to fill the whole window so it
|
||||
* matches the `MAGN`-scaled blit at the end of [renderGame]. Handed to
|
||||
* [IOSpace.inputViewport] so mouse coordinates unproject correctly.
|
||||
*/
|
||||
lateinit var inputViewport: Viewport
|
||||
|
||||
var gpu: GraphicsAdapter? = null
|
||||
lateinit var vmRunner: VMRunner
|
||||
lateinit var coroutineJob: Thread
|
||||
@@ -103,9 +113,20 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
gpuFBO = FrameBuffer(Pixmap.Format.RGBA8888, viewportWidth, viewportHeight, false)
|
||||
winFBO = FrameBuffer(Pixmap.Format.RGBA8888, viewportWidth, viewportHeight, false)
|
||||
|
||||
val inputCam = OrthographicCamera().also {
|
||||
it.setToOrtho(true, viewportWidth.toFloat(), viewportHeight.toFloat())
|
||||
}
|
||||
inputViewport = StretchViewport(viewportWidth.toFloat(), viewportHeight.toFloat(), inputCam)
|
||||
inputViewport.update(Gdx.graphics.width, Gdx.graphics.height, true)
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
override fun resize(width: Int, height: Int) {
|
||||
super.resize(width, height)
|
||||
inputViewport.update(width, height, true)
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
vm.init()
|
||||
|
||||
@@ -148,6 +169,11 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
}
|
||||
|
||||
Gdx.input.inputProcessor = vm.getIO()
|
||||
vm.getIO().inputViewport = inputViewport
|
||||
vm.getIO().inputOriginX = (viewportWidth - loaderInfo.drawWidth) / 2
|
||||
vm.getIO().inputOriginY = (viewportHeight - loaderInfo.drawHeight) / 2
|
||||
vm.getIO().inputAreaW = loaderInfo.drawWidth
|
||||
vm.getIO().inputAreaH = loaderInfo.drawHeight
|
||||
|
||||
if (usememvwr) memvwr = Memvwr(vm)
|
||||
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
# Created by CuriousTorvald and Claude on 2025-08-17.
|
||||
# Makefile for TSVM Enhanced Video (TEV) encoder and libraries
|
||||
|
||||
CC = gcc
|
||||
CXX = g++
|
||||
CFLAGS = -std=c99 -Wall -Wextra -Ofast -D_GNU_SOURCE -march=native -mavx512f -mavx512dq -mavx512bw -mavx512vl -Iinclude
|
||||
CXXFLAGS = -std=c++11 -Wall -Wextra -Ofast -D_GNU_SOURCE -march=native -mavx512f -mavx512dq -mavx512bw -mavx512vl -Iinclude
|
||||
DBGFLAGS =
|
||||
PREFIX = /usr/local
|
||||
|
||||
# Zstd flags (use pkg-config if available, fallback for cross-platform compatibility)
|
||||
ZSTD_CFLAGS = $(shell pkg-config --cflags libzstd 2>/dev/null || echo "")
|
||||
ZSTD_LIBS = $(shell pkg-config --libs libzstd 2>/dev/null || echo "-lzstd")
|
||||
LIBS = -lm $(ZSTD_LIBS)
|
||||
|
||||
# =============================================================================
|
||||
# Library Object Files
|
||||
# =============================================================================
|
||||
|
||||
# libtavenc - TAV encoder library
|
||||
LIBTAVENC_OBJ = lib/libtavenc/tav_encoder_lib.o \
|
||||
lib/libtavenc/tav_encoder_color.o \
|
||||
lib/libtavenc/tav_encoder_dwt.o \
|
||||
lib/libtavenc/tav_encoder_quantize.o \
|
||||
lib/libtavenc/tav_encoder_ezbc.o \
|
||||
lib/libtavenc/tav_encoder_utils.o \
|
||||
lib/libtavenc/tav_encoder_tile.o
|
||||
|
||||
# libtavdec - TAV decoder library
|
||||
LIBTAVDEC_OBJ = lib/libtavdec/tav_video_decoder.o
|
||||
|
||||
# libtadenc - TAD encoder library
|
||||
LIBTADENC_OBJ = lib/libtadenc/encoder_tad.o
|
||||
|
||||
# libtaddec - TAD decoder library
|
||||
LIBTADDEC_OBJ = lib/libtaddec/decoder_tad.o
|
||||
|
||||
# libfec - Forward Error Correction library (LDPC + Reed-Solomon)
|
||||
LIBFEC_OBJ = lib/libfec/ldpc.o lib/libfec/reed_solomon.o lib/libfec/ldpc_payload.o
|
||||
|
||||
# =============================================================================
|
||||
# Targets
|
||||
# =============================================================================
|
||||
|
||||
# Source files and targets
|
||||
TARGETS = libs encoder_tav_ref decoder_tav_ref tav_inspector tad tav_dt
|
||||
LIBRARIES = lib/libtavenc.a lib/libtavdec.a lib/libtadenc.a lib/libtaddec.a lib/libfec.a
|
||||
TAV_TARGETS = encoder_tav_ref decoder_tav_ref tav_inspector
|
||||
TAD_TARGETS = encoder_tad decoder_tad
|
||||
DT_TARGETS = encoder_tav_dt decoder_tav_dt tavdt_noise_injector
|
||||
|
||||
# Build all encoders (default)
|
||||
all: clean $(TARGETS)
|
||||
|
||||
# Build all libraries
|
||||
libs: $(LIBRARIES)
|
||||
|
||||
# Reference encoder using libtavenc (replaces old monolithic encoder)
|
||||
encoder_tav_ref: src/encoder_tav.c lib/libtavenc.a lib/libtadenc.a
|
||||
rm -f encoder_tav_ref
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -Iinclude -o encoder_tav_ref src/encoder_tav.c lib/libtavenc.a lib/libtadenc.a $(LIBS)
|
||||
@echo ""
|
||||
@echo "Reference encoder built: encoder_tav_ref"
|
||||
@echo "This is the official reference implementation with all features"
|
||||
|
||||
# Reference decoder using libtavdec (replaces old monolithic decoder)
|
||||
decoder_tav_ref: src/decoder_tav.c lib/libtavdec.a lib/libtaddec.a
|
||||
rm -f decoder_tav_ref
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -Iinclude -o decoder_tav_ref src/decoder_tav.c lib/libtavdec.a lib/libtaddec.a $(LIBS)
|
||||
@echo ""
|
||||
@echo "Reference decoder built: decoder_tav_ref"
|
||||
@echo "This is the official reference implementation with all features"
|
||||
|
||||
tav_inspector: tav_inspector.c lib/libfec.a
|
||||
rm -f tav_inspector
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -Ilib/libfec -o tav_inspector $< lib/libfec.a $(LIBS)
|
||||
|
||||
tav: $(TAV_TARGETS)
|
||||
|
||||
# Build TAD (Terrarum Advanced Audio) tools
|
||||
encoder_tad: src/encoder_tad_standalone.c lib/libtadenc/encoder_tad.c include/encoder_tad.h
|
||||
rm -f encoder_tad encoder_tad_standalone.o encoder_tad.o
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -c lib/libtadenc/encoder_tad.c -o encoder_tad.o
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -c src/encoder_tad_standalone.c -o encoder_tad_standalone.o
|
||||
$(CC) $(DBGFLAGS) -o encoder_tad encoder_tad_standalone.o encoder_tad.o $(LIBS)
|
||||
|
||||
decoder_tad: lib/libtaddec/decoder_tad.c
|
||||
rm -f decoder_tad
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -o decoder_tad $< $(LIBS)
|
||||
|
||||
# Build all TAD tools
|
||||
tad: $(TAD_TARGETS)
|
||||
|
||||
# =============================================================================
|
||||
# Library Build Rules
|
||||
# =============================================================================
|
||||
|
||||
# Compile library object files
|
||||
lib/libtavenc/%.o: lib/libtavenc/%.c
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -c $< -o $@
|
||||
|
||||
lib/libtavdec/%.o: lib/libtavdec/%.c
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -c $< -o $@
|
||||
|
||||
lib/libtadenc/%.o: lib/libtadenc/%.c
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -c $< -o $@
|
||||
|
||||
lib/libtaddec/%.o: lib/libtaddec/%.c
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -DTAD_DECODER_LIB -c $< -o $@
|
||||
|
||||
lib/libfec/%.o: lib/libfec/%.c
|
||||
$(CC) $(CFLAGS) -Ilib/libfec -c $< -o $@
|
||||
|
||||
# Build static libraries
|
||||
lib/libtavenc.a: $(LIBTAVENC_OBJ)
|
||||
ar rcs $@ $^
|
||||
|
||||
lib/libtavdec.a: $(LIBTAVDEC_OBJ)
|
||||
ar rcs $@ $^
|
||||
|
||||
lib/libtadenc.a: $(LIBTADENC_OBJ)
|
||||
ar rcs $@ $^
|
||||
|
||||
lib/libtaddec.a: $(LIBTADDEC_OBJ)
|
||||
ar rcs $@ $^
|
||||
|
||||
lib/libfec.a: $(LIBFEC_OBJ)
|
||||
ar rcs $@ $^
|
||||
|
||||
# =============================================================================
|
||||
# TAV-DT (Digital Tape) Encoder/Decoder
|
||||
# =============================================================================
|
||||
|
||||
# TAV-DT encoder with FEC (multithreaded)
|
||||
encoder_tav_dt: src/encoder_tav_dt.c lib/libtavenc.a lib/libtadenc.a lib/libfec.a
|
||||
rm -f encoder_tav_dt
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -Iinclude -Ilib/libfec -o encoder_tav_dt src/encoder_tav_dt.c lib/libtavenc.a lib/libtadenc.a lib/libfec.a $(LIBS) -lpthread
|
||||
@echo ""
|
||||
@echo "TAV-DT encoder built: encoder_tav_dt"
|
||||
@echo "Digital Tape format with LDPC and Reed-Solomon FEC (multithreaded)"
|
||||
|
||||
# TAV-DT decoder with FEC (multithreaded)
|
||||
decoder_tav_dt: src/decoder_tav_dt.c lib/libtavdec.a lib/libtaddec.a lib/libfec.a
|
||||
rm -f decoder_tav_dt
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -Iinclude -Ilib/libfec -o decoder_tav_dt src/decoder_tav_dt.c lib/libtavdec.a lib/libtaddec.a lib/libfec.a $(LIBS) -lpthread
|
||||
@echo ""
|
||||
@echo "TAV-DT decoder built: decoder_tav_dt"
|
||||
@echo "Digital Tape format with LDPC and Reed-Solomon FEC (multithreaded)"
|
||||
|
||||
# TAV-DT noise injector (channel simulator)
|
||||
tavdt_noise_injector: tavdt_noise_injector.c
|
||||
rm -f tavdt_noise_injector
|
||||
$(CC) -std=c99 -Wall -Ofast -D_GNU_SOURCE -o tavdt_noise_injector tavdt_noise_injector.c -lm
|
||||
@echo ""
|
||||
@echo "TAV-DT noise injector built: tavdt_noise_injector"
|
||||
@echo "Simulates QPSK satellite channel noise (AWGN + burst)"
|
||||
|
||||
# Build all TAV-DT tools
|
||||
tav_dt: $(DT_TARGETS)
|
||||
|
||||
# Build with debug symbols
|
||||
debug: CFLAGS += -g -DDEBUG -fsanitize=address -fno-omit-frame-pointer
|
||||
debug: DBGFLAGS += -fsanitize=address -fno-omit-frame-pointer
|
||||
debug: clean $(TARGETS)
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -f $(TARGETS) $(TAD_TARGETS) $(DT_TARGETS) $(LIBRARIES) *.o lib/*/*.o
|
||||
|
||||
# Install (copy to PATH)
|
||||
install: $(TARGETS)
|
||||
cp encoder_tav_ref $(PREFIX)/bin/
|
||||
cp decoder_tav_ref $(PREFIX)/bin/
|
||||
cp encoder_tad $(PREFIX)/bin/
|
||||
cp decoder_tad $(PREFIX)/bin/
|
||||
cp encoder_tav_dt $(PREFIX)/bin/
|
||||
cp decoder_tav_dt $(PREFIX)/bin/
|
||||
cp tav_inspector $(PREFIX)/bin/
|
||||
|
||||
# Check for required dependencies
|
||||
check-deps:
|
||||
@echo "Checking dependencies..."
|
||||
@pkg-config --exists libzstd || (echo "Error: libzstd-dev not found. Install libzstd-dev or equivalent" && exit 1)
|
||||
@echo "All dependencies found."
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "TSVM Advanced Video (TAV) and Audio (TAD) Encoders"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " all - Build video encoders (default)"
|
||||
@echo " libs - Build all codec libraries (.a files)"
|
||||
@echo " tav - Build the TAV advanced video encoder"
|
||||
@echo " tav_dt - Build all TAV-DT (Digital Tape) tools with FEC"
|
||||
@echo " tavdt_noise_injector - Build TAV-DT channel noise simulator"
|
||||
@echo " tad - Build all TAD audio tools (encoder, decoder)"
|
||||
@echo " encoder_tad - Build TAD audio encoder"
|
||||
@echo " decoder_tad - Build TAD audio decoder"
|
||||
@echo " tests - Build test programs"
|
||||
@echo " debug - Build with debug symbols"
|
||||
@echo " clean - Remove build artifacts"
|
||||
@echo " install - Install to /usr/local/bin"
|
||||
@echo " check-deps - Check for required dependencies"
|
||||
@echo " help - Show this help"
|
||||
@echo ""
|
||||
@echo "Libraries:"
|
||||
@echo " lib/libtavenc.a - TAV encoder library"
|
||||
@echo " lib/libtavdec.a - TAV decoder library"
|
||||
@echo " lib/libtadenc.a - TAD encoder library"
|
||||
@echo " lib/libtaddec.a - TAD decoder library"
|
||||
@echo " lib/libfec.a - Forward Error Correction library (LDPC + RS)"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make # Build video encoders"
|
||||
@echo " make libs # Build all libraries"
|
||||
@echo " make tav # Build TAV encoder"
|
||||
@echo " make tav_dt # Build TAV-DT encoder/decoder with FEC"
|
||||
@echo " make tad # Build all TAD audio tools"
|
||||
@echo " sudo make install # Install all encoders"
|
||||
|
||||
.PHONY: all libs clean install check-deps help debug tad tav_dt tests
|
||||
@@ -1,350 +0,0 @@
|
||||
# TAD - TSVM Advanced Audio Codec
|
||||
|
||||
A perceptually-optimised wavelet-based audio codec designed for resource-constrained systems, featuring CDF 9/7 wavelets, EZBC sparse coding, and sophisticated perceptual quantisation.
|
||||
|
||||
## Overview
|
||||
|
||||
TAD (TSVM Advanced Audio) is a modern audio codec built on discrete wavelet transform (DWT) using Cohen-Daubechies-Feauveau (CDF) 9/7 biorthogonal wavelets. It combines perceptual quantisation, advanced entropy coding, and careful optimisation for resource-constrained systems.
|
||||
|
||||
### Key Advantages
|
||||
|
||||
- **Perceptual optimisation**: HVS-aware quantisation preserves audio quality where it matters
|
||||
- **Efficient sparse coding**: EZBC encoding exploits coefficient sparsity (86.9% zeros in typical content)
|
||||
- **Variable chunk sizes**: Supports any chunk size ≥1024 samples, including non-power-of-2
|
||||
- **Stereo decorrelation**: Mid/Side encoding exploits stereo correlation for better compression
|
||||
- **Hardware-friendly**: Designed for efficient decoding on resource-constrained platforms
|
||||
|
||||
## Features
|
||||
|
||||
### Compression Technology
|
||||
|
||||
- **CDF 9/7 Biorthogonal Wavelets**
|
||||
- 9-level fixed decomposition for all chunk sizes
|
||||
- Lifting scheme implementation for efficient computation
|
||||
- Optimal frequency discrimination for audio signals
|
||||
|
||||
- **Pre-processing**
|
||||
- First-order IIR pre-emphasis filter (α=0.5) shifts quantisation noise to lower frequencies, where they are less objectionable to listeners
|
||||
- Gamma companding (γ=0.5) for dynamic range compression before quantisation
|
||||
- Mid/Side stereo transformation exploits stereo correlation
|
||||
- Lambda companding (λ=6.0) with Laplacian CDF mapping for full bit utilisation
|
||||
|
||||
- **Perceptual Quantisation**
|
||||
- Channel-specific (Mid/Side) frequency-dependent weights
|
||||
- Subband-aware quantisation preserves perceptually important frequencies
|
||||
|
||||
- **EZBC Encoding**
|
||||
- Binary tree embedded zero block coding
|
||||
- Exploits coefficient sparsity (86.9% Mid, 97.8% Side typical)
|
||||
- Progressive refinement structure
|
||||
- Spatial clustering of non-zero coefficients
|
||||
|
||||
- **Entropy Coding**
|
||||
- Zstandard compression (level 7) on concatenated EZBC bitstreams
|
||||
- Cross-channel compression optimisation
|
||||
- Optional Zstd bypass for debugging
|
||||
|
||||
### Audio Format
|
||||
|
||||
- **Sample Rate**: 32 KHz (TSVM audio hardware native format)
|
||||
- **Channels**: Stereo (L/R input, Mid/Side internal representation)
|
||||
- **Chunk Sizes**: Variable, any size ≥1024 samples (including non-power-of-2)
|
||||
- **Bit Depth**: 32-bit float internal, 8-bit unsigned PCM output with noise-shaped dithering
|
||||
- **Bandwidth**: Full 0-16 KHz frequency range preserved
|
||||
|
||||
### Quality Levels
|
||||
|
||||
Six quality levels (0-5) provide a wide range of compression/quality trade-offs:
|
||||
- **Level 0**: Lowest quality, smallest file size
|
||||
- **Level 3**: Default, balanced quality/compression (2.51:1 vs PCMu8)
|
||||
- **Level 5**: Highest quality, largest file size
|
||||
|
||||
Quality levels are designed to be synchronised with TAV video codec for unified encoding.
|
||||
|
||||
## Building
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- C compiler (GCC/Clang)
|
||||
- Zstandard library (libzstd)
|
||||
- Math library (libm)
|
||||
|
||||
### Compilation
|
||||
|
||||
```bash
|
||||
# Build TAD encoder/decoder
|
||||
make tad
|
||||
|
||||
# Build all tools
|
||||
make all
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
```
|
||||
|
||||
### Build Targets
|
||||
|
||||
- `encoder_tad` - Standalone audio encoder with FFmpeg calls
|
||||
- `decoder_tad` - Standalone audio decoder
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Encoding
|
||||
|
||||
Encoding requires FFmpeg executable installed in your system.
|
||||
|
||||
```bash
|
||||
# Default encoding (quality level 3)
|
||||
./encoder_tad -i input.mp3 -o output.tad
|
||||
|
||||
# Specify quality level (0-5)
|
||||
./encoder_tad -i input.m4a -o output.tad -q 0 # Lowest quality
|
||||
./encoder_tad -i input.ogg -o output.tad -q 5 # Highest quality
|
||||
|
||||
# Disable Zstd compression (for debugging)
|
||||
./encoder_tad -i input.opus -o output.tad --no-zstd
|
||||
|
||||
# Verbose output with statistics
|
||||
./encoder_tad -i input.flac -o output.tad -v
|
||||
```
|
||||
|
||||
### Decoding
|
||||
|
||||
```bash
|
||||
# Decode to PCMu8
|
||||
./decoder_tad -i input.tad -o output.pcm --raw-pcm
|
||||
|
||||
# Decode to WAV
|
||||
./decoder_tad -i input.tad -o output.wav
|
||||
```
|
||||
|
||||
### Input Formats
|
||||
|
||||
TAD encoder accepts any audio format supported by FFmpeg:
|
||||
- Audio files: WAV, MP3, FLAC, OGG, AAC, etc.
|
||||
- Video files with audio streams: MP4, MKV, AVI, etc.
|
||||
- Raw PCM formats
|
||||
|
||||
Audio is automatically resampled to 32 KHz stereo if necessary.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Encoder Pipeline
|
||||
|
||||
1. **Input Processing**
|
||||
- FFmpeg demuxing and audio stream extraction
|
||||
- Resampling to 32 KHz stereo
|
||||
- Conversion to PCM32f
|
||||
|
||||
2. **Pre-emphasis Filter**
|
||||
- First-order IIR filter with α=0.5
|
||||
- Shifts quantisation noise toward lower frequencies
|
||||
- Improves perceptual quality
|
||||
|
||||
3. **Gamma Companding**
|
||||
- Dynamic range compression with γ=0.5
|
||||
- Applied independently to each sample
|
||||
- Reduces quantisation error for low-amplitude signals
|
||||
|
||||
4. **Stereo Decorrelation**
|
||||
- Left/Right to Mid/Side transformation
|
||||
- Mid = (L + R) / 2
|
||||
- Side = (L - R) / 2
|
||||
- Exploits stereo correlation for better compression
|
||||
|
||||
5. **9-Level CDF 9/7 DWT**
|
||||
- Fixed 9 decomposition levels for all chunk sizes
|
||||
- Forward lifting scheme implementation
|
||||
- Correct length tracking for non-power-of-2 sizes
|
||||
|
||||
6. **Perceptual Quantisation**
|
||||
- Channel-specific (Mid/Side) subband weights
|
||||
- Lambda companding with λ=6.0
|
||||
- Laplacian CDF mapping: `sign(x) * floor(λ * log(1 + |x|/λ))`
|
||||
- Quantised to int8 coefficients
|
||||
|
||||
7. **EZBC Encoding**
|
||||
- Binary tree structure per channel
|
||||
- Progressive refinement by bitplanes
|
||||
- Zero block coding exploits sparsity
|
||||
- Independent bitstreams for Mid and Side
|
||||
|
||||
8. **Zstd Compression**
|
||||
- Level 7 compression on concatenated `[Mid_bitstream][Side_bitstream]`
|
||||
- Cross-channel optimisation opportunities
|
||||
- Adaptive compression based on content
|
||||
|
||||
### Decoder Pipeline
|
||||
|
||||
1. **Container Parsing**
|
||||
- TAD packet identification (type 0x24)
|
||||
- Chunk size extraction
|
||||
- Compressed data boundaries
|
||||
|
||||
2. **Zstd Decompression**
|
||||
- Decompress concatenated bitstreams
|
||||
- Split into Mid and Side EZBC streams
|
||||
|
||||
3. **EZBC Decoding**
|
||||
- Binary tree decoder per channel
|
||||
- Reconstruct quantised int8 coefficients
|
||||
- Progressive refinement reconstruction
|
||||
|
||||
4. **Lambda Decompanding**
|
||||
- Inverse Laplacian CDF with channel-specific weights
|
||||
- Reconstruct float32 DWT coefficients
|
||||
- Apply subband-specific perceptual weights
|
||||
|
||||
5. **9-Level Inverse CDF 9/7 DWT**
|
||||
- Inverse lifting scheme implementation
|
||||
- Correct length tracking for non-power-of-2 chunk sizes
|
||||
- Pre-calculated length sequence from forward transform
|
||||
|
||||
6. **Mid/Side to Left/Right**
|
||||
- L = Mid + Side
|
||||
- R = Mid - Side
|
||||
- Reconstruct stereo channels
|
||||
|
||||
7. **Gamma Decompanding**
|
||||
- Inverse gamma with γ⁻¹=2.0
|
||||
- Restore original dynamic range
|
||||
|
||||
8. **De-emphasis Filter**
|
||||
- Reverse pre-emphasis with α=0.5
|
||||
- Remove frequency shaping
|
||||
- Restore flat frequency response
|
||||
|
||||
9. **PCM32f to PCM8u Conversion**
|
||||
- Noise-shaped dithering for 8-bit output
|
||||
- Clamping to valid range
|
||||
- Final output format
|
||||
|
||||
### Wavelet Implementation
|
||||
|
||||
CDF 9/7 wavelet follows a **two-stage lifting scheme**:
|
||||
|
||||
```c
|
||||
// Forward Transform: Predict → Update
|
||||
// Predict step (generate high-pass)
|
||||
temp[half + i] = data[odd] - α * (data[even_left] + data[even_right]);
|
||||
|
||||
// Update step (generate low-pass)
|
||||
temp[i] = data[even] + β * (temp[half + i - 1] + temp[half + i]);
|
||||
|
||||
// Normalization (K factor)
|
||||
temp[i] *= K;
|
||||
temp[half + i] /= K;
|
||||
|
||||
// Inverse Transform: Denormalize → Undo Update → Undo Predict (reversed order)
|
||||
temp[i] /= K;
|
||||
temp[half + i] *= K;
|
||||
|
||||
temp[i] -= β * (temp[half + i - 1] + temp[half + i]);
|
||||
data[odd] = temp[half + i] + α * (temp[i] + temp[i + 1]);
|
||||
data[even] = temp[i];
|
||||
```
|
||||
|
||||
**CDF 9/7 Coefficients**:
|
||||
- α = -1.586134342
|
||||
- β = -0.052980118
|
||||
- γ = +0.882911075
|
||||
- δ = +0.443506852
|
||||
- K = 1.230174105
|
||||
|
||||
### Non-Power-of-2 Chunk Size Handling
|
||||
|
||||
Critical implementation detail for variable chunk sizes:
|
||||
|
||||
```c
|
||||
// Pre-calculate exact length sequence from forward transform
|
||||
int lengths[MAX_LEVELS + 1];
|
||||
lengths[0] = chunk_size;
|
||||
for (int i = 1; i <= levels; i++) {
|
||||
lengths[i] = (lengths[i - 1] + 1) / 2;
|
||||
}
|
||||
|
||||
// Apply inverse DWT using lengths[level] for each level
|
||||
// NEVER use simple doubling (length *= 2) - incorrect for non-power-of-2!
|
||||
```
|
||||
|
||||
Incorrect length tracking causes mirrored subband artefacts in decoded audio.
|
||||
|
||||
### Perceptual Quantisation Weights
|
||||
|
||||
Channel-specific weights for Mid (channel 0) and Side (channel 1):
|
||||
|
||||
```c
|
||||
// Base quantiser weights per subband (9 levels + approximation)
|
||||
float BASE_QUANTISER_WEIGHTS[2][10] = {
|
||||
// Mid channel (0)
|
||||
{4.0f, 2.0f, 1.8f, 1.6f, 1.4f, 1.2f, 1.0f, 1.0f, 1.3f, 2.0f},
|
||||
|
||||
// Side channel (1)
|
||||
{6.0f, 5.0f, 2.6f, 2.4f, 1.8f, 1.3f, 1.0f, 1.0f, 1.6f, 3.2f}
|
||||
};
|
||||
|
||||
// During dequantisation:
|
||||
float weight = BASE_QUANTISER_WEIGHTS[channel][subband] * quantiser_scale;
|
||||
coeffs[i] = normalised_val * TAD32_COEFF_SCALARS[subband] * weight;
|
||||
```
|
||||
|
||||
Different weights for Mid and Side channels reflect perceptual importance of frequency bands in each channel. DC frequency has highest weight (4.0 Mid, 6.0 Side) due to energy concentration.
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Compression Efficiency
|
||||
|
||||
- **Target Compression**: 2:1 against PCMu8 baseline (4:1 against PCM16LE input)
|
||||
- **Achieved Compression**: 2.51:1 against PCMu8 at quality level 3
|
||||
- **Audio Quality**: Preserves full 0-16 KHz bandwidth
|
||||
- **Coefficient Sparsity**: 86.9% zeros in Mid channel, 97.8% in Side channel (typical)
|
||||
- **EZBC Benefits**: Exploits sparsity, progressive refinement, spatial clustering
|
||||
|
||||
### Computational Complexity
|
||||
|
||||
- **Encoding**: O(n log n) per chunk for DWT, O(n) for EZBC encoding
|
||||
- **Decoding**: O(n log n) per chunk for inverse DWT, O(n) for EZBC decoding
|
||||
- **Memory**: O(n) working memory for chunk processing
|
||||
|
||||
### Quality Characteristics
|
||||
|
||||
- **Frequency Response**: Flat 0-16 KHz within perceptual limits
|
||||
- **Dynamic Range**: Preserved through gamma companding
|
||||
- **Stereo Imaging**: Maintained through Mid/Side decorrelation
|
||||
- **Perceptual Quality**: Optimised for human auditory system characteristics
|
||||
|
||||
## Integration with TAV
|
||||
|
||||
TAD is designed as an includable API for TAV video encoder integration:
|
||||
|
||||
- **Variable Chunk Sizes**: Audio chunks can match video GOP boundaries (e.g., 32016 samples for 1-second TAV GOP)
|
||||
- **Unified Quality Levels**: TAD quality 0-5 synchronised with TAV quality 0-5
|
||||
- **Embedded Packets**: TAV embeds TAD-compressed audio using packet type 0x24
|
||||
- **Shared Container**: Single .tav file contains both video and audio streams
|
||||
|
||||
### TAV Integration Example
|
||||
|
||||
```c
|
||||
// TAD handles non-power-of-2 chunk size correctly
|
||||
tad_encode_chunk(audio_buffer, audio_samples_per_gop, output_buffer, &output_size);
|
||||
|
||||
// TAV embeds TAD packet
|
||||
tav_write_packet(TAV_PACKET_AUDIO, output_buffer, output_size);
|
||||
```
|
||||
|
||||
## Format Specification
|
||||
|
||||
For complete packet structure and bitstream format details, refer to `format documentation.txt`.
|
||||
|
||||
### Key Packet Types
|
||||
|
||||
- `0x24`: TAD audio packet (used in standalone .tad files and embedded in .tav files)
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **TAV** (TSVM Advanced Video): Wavelet-based video codec with integrated TAD audio
|
||||
- **TSVM**: Target virtual machine platform for TAD playback
|
||||
|
||||
## Licence
|
||||
|
||||
MIT.
|
||||
@@ -1,261 +0,0 @@
|
||||
# TAV - TSVM Advanced Video Codec
|
||||
|
||||
A perceptually-optimised wavelet-based video codec designed for resource-constrained systems, featuring multiple wavelet types, temporal 3D DWT, and sophisticated compression techniques.
|
||||
|
||||
## Overview
|
||||
|
||||
TAV (TSVM Advanced Video) is a modern video codec built on discrete wavelet transformation (DWT). It combines cutting-edge compression techniques with careful optimisation for resource-constrained systems.
|
||||
|
||||
### Key Advantages
|
||||
|
||||
- **No blocking artefacts**: Large-tile DWT encoding with padding eliminates DCT block boundaries
|
||||
- **No colour banding**: Wavelets spreads gradients across scales, preventing banding in the first place
|
||||
- **Perceptual optimisation**: HVS-aware quantisation preserves visual quality where it matters
|
||||
- **Temporal coherence**: 3D DWT with GOP encoding exploits inter-frame similarity
|
||||
- **Efficient sparse coding**: EZBC encoding exploits coefficient sparsity for 16-18% additional compression
|
||||
- **Hardware-friendly**: Designed for efficient decoding on resource-constrained platforms
|
||||
|
||||
## Features
|
||||
|
||||
### Compression Technology
|
||||
|
||||
- **Wavelet Types**
|
||||
- **5/3 Reversible** (JPEG 2000 standard): Lossless-capable, good for archival
|
||||
- **9/7 Irreversible** (default): Best overall compression, CDF 9/7 variant
|
||||
|
||||
- **Spatial Encoding**
|
||||
- Large-tile encoding with padding, with optional single-tile mode (no blocking artefacts)
|
||||
- 6-level DWT decomposition for deep frequency analysis
|
||||
- Perceptual quantisation with HVS-optimised coefficient scaling
|
||||
- YCoCg-R colour space with anisotropic chroma quantisation
|
||||
|
||||
- **Temporal Encoding** (3D DWT Mode)
|
||||
- Group-of-pictures (GOP) encoding with adaptive size (typically 20 frames)
|
||||
- Unified EZBC encoding across temporal dimension
|
||||
- Adaptive GOP boundaries with scene change detection
|
||||
|
||||
- **EZBC Encoding**
|
||||
- Binary tree embedded zero block coding exploits coefficient sparsity
|
||||
- Progressive refinement structure with bitplane encoding
|
||||
- Concatenated channel layout for cross-channel compression optimisation
|
||||
- Typical sparsity: 86.9% (Y), 97.8% (Co), 99.5% (Cg)
|
||||
- 16-18% compression improvement over naive coefficient encoding
|
||||
|
||||
### Audio Integration
|
||||
|
||||
TAV seamlessly integrates with the TAD (TSVM Advanced Audio) codec for synchronised audio/video encoding:
|
||||
- Variable chunk sizes match video GOP boundaries
|
||||
- Embedded TAD packets (type 0x24) with Zstd compression
|
||||
- Unified container format
|
||||
|
||||
## Building
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- C compiler (GCC/Clang)
|
||||
- Zstandard library
|
||||
- OpenCV 4 library (only used by experimental motion estimation feature)
|
||||
|
||||
### Compilation
|
||||
|
||||
```bash
|
||||
# Build TAV encoder/decoder
|
||||
make tav
|
||||
|
||||
# Build all tools including TAD audio codec
|
||||
make all
|
||||
|
||||
# Clean build artefacts
|
||||
make clean
|
||||
```
|
||||
|
||||
### Build Targets
|
||||
|
||||
- `encoder_tav` - Main video encoder
|
||||
- `decoder_tav` - Standalone video decoder
|
||||
- `tav_inspector` - Packet analysis and debugging tool
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Encoding
|
||||
|
||||
Encoding requires FFmpeg executable installed in your system.
|
||||
|
||||
```bash
|
||||
# Default encoding (CDF 9/7 wavelet, quality level 3)
|
||||
./encoder_tav -i input.mp4 -o output.tav
|
||||
|
||||
# Quality levels (0-5)
|
||||
./encoder_tav -i input.avi -q 0 -o output.tav # Lowest quality, smallest file
|
||||
./encoder_tav -i input.mkv -q 5 -o output.tav # Highest quality, largest file
|
||||
```
|
||||
|
||||
### Intra-only Encoding
|
||||
|
||||
```bash
|
||||
# Enable Intra-only encoding
|
||||
./encoder_tav -i input.mp4 --intra-only -o output.tav
|
||||
```
|
||||
|
||||
### Decoding and Inspection
|
||||
|
||||
```bash
|
||||
# Decode TAV to raw video
|
||||
./decoder_tav -i input.tav -o output.mkv
|
||||
|
||||
# Inspect packet structure (debugging)
|
||||
./tav_inspector input.tav -v
|
||||
```
|
||||
|
||||
### Frame Limiting
|
||||
|
||||
```bash
|
||||
# Encode only first N frames (useful for testing)
|
||||
./encoder_tav -i input.mp4 -o output.tav --encode-limit 100
|
||||
```
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Encoder Pipeline
|
||||
|
||||
1. **Input Processing**
|
||||
- FFmpeg demuxing and frame extraction
|
||||
- RGB to YCoCg-R colour space conversion
|
||||
- Resolution validation and padding
|
||||
|
||||
2. **DWT Transform**
|
||||
- Spatial: 6-level decomposition per frame
|
||||
- Temporal: 1D DWT across GOP frames (3D DWT mode)
|
||||
- Lifting scheme implementation for all wavelets
|
||||
|
||||
3. **Perceptual Quantisation**
|
||||
- HVS-based subband weights
|
||||
- Anisotropic chroma quantisation (YCoCg-R specific)
|
||||
- Quality-dependent quantisation matrices
|
||||
|
||||
4. **EZBC Encoding**
|
||||
- Binary tree embedded zero block coding per channel
|
||||
- Progressive refinement by bitplanes
|
||||
- Concatenated bitstream layout: `[Y_bitstream][Co_bitstream][Cg_bitstream]`
|
||||
- Cross-channel compression optimisation
|
||||
|
||||
5. **Entropy Coding**
|
||||
- Zstandard compression (level 7) on concatenated EZBC bitstreams
|
||||
- Cross-channel compression opportunities
|
||||
- Adaptive compression based on GOP structure
|
||||
|
||||
### Decoder Pipeline
|
||||
|
||||
1. **Container Parsing**
|
||||
- Packet type identification (0x00-0xFF)
|
||||
- Timecode synchronisation
|
||||
- GOP boundary detection
|
||||
|
||||
2. **Entropy Decoding**
|
||||
- Zstd decompression of concatenated bitstreams
|
||||
- EZBC binary tree decoding per channel
|
||||
- Progressive coefficient reconstruction
|
||||
|
||||
3. **Inverse Quantisation**
|
||||
- Perceptual weight application
|
||||
- Subband-specific scaling
|
||||
- Coefficient reconstruction from sparse representation
|
||||
|
||||
4. **Inverse DWT**
|
||||
- Temporal: 1D inverse DWT across frames (3D DWT mode)
|
||||
- Spatial: 6-level inverse wavelet reconstruction
|
||||
|
||||
5. **Output Conversion**
|
||||
- YCoCg-R to RGB colour space
|
||||
- Clamping and dithering
|
||||
- Frame buffering for display
|
||||
|
||||
### Wavelet Implementation
|
||||
|
||||
All wavelets follow a **lifting scheme** pattern with symmetric boundary extension:
|
||||
|
||||
```c
|
||||
// Forward Transform: Predict → Update
|
||||
temp[half + i] = data[odd] - predict(data[even]); // High-pass
|
||||
temp[i] = data[even] + update(temp[half]); // Low-pass
|
||||
|
||||
// Inverse Transform: Undo Update → Undo Predict (reversed order)
|
||||
data[even] = temp[i] - update(temp[half]); // Undo low-pass
|
||||
data[odd] = temp[half + i] + predict(data[even]); // Undo high-pass
|
||||
```
|
||||
|
||||
**Critical**: Forward and inverse transforms must use identical coefficient indexing and exactly reverse operations to avoid grid artefacts.
|
||||
|
||||
### Coefficient Layout
|
||||
|
||||
TAV uses **2D Spatial Layout** in memory for each decomposition level:
|
||||
|
||||
```
|
||||
[LL] [LH] [HL] [HH] [LH] [HL] [HH] ...
|
||||
└── Level 0 ──┘ └─── Level 1 ───┘
|
||||
```
|
||||
|
||||
- `LL`: Low-pass (approximation) - progressively smaller with each level
|
||||
- `LH`, `HL`, `HH`: High-pass subbands (horizontal, vertical, diagonal detail)
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Compression Efficiency
|
||||
|
||||
- **Sparsity Exploitation**: Typical quantised coefficient sparsity
|
||||
- Y channel: 86.9% zeros
|
||||
- Co channel: 97.8% zeros
|
||||
- Cg channel: 99.5% zeros
|
||||
|
||||
- **EZBC Benefits**: 16-18% compression improvement over naive coefficient encoding through sparsity exploitation
|
||||
|
||||
- **Temporal Coherence**: Additional 15-25% improvement with 3D DWT (content-dependent)
|
||||
|
||||
### Computational Complexity
|
||||
|
||||
- **Encoding**: O(n log n) per frame for spatial DWT
|
||||
- **Decoding**: O(n log n) per frame, optimised lifting scheme implementation
|
||||
- **Memory**: Single-tile encoding requires O(w × h) working memory
|
||||
|
||||
### Quality Characteristics
|
||||
|
||||
- **No blocking artefacts**: Wavelet-based encoding is inherently smooth
|
||||
- **Perceptual optimisation**: Better subjective quality than bitrate-equivalent DCT codecs
|
||||
- **Scalability**: 6 quality levels (0-5) provide wide range of bitrate/quality trade-offs
|
||||
- **Temporal stability**: 3D DWT mode reduces flickering and temporal artefacts
|
||||
|
||||
## Format Specification
|
||||
|
||||
For complete packet structure and bitstream format details, refer to `format documentation.txt`.
|
||||
|
||||
### Key Packet Types
|
||||
|
||||
- `0x00`: Metadata and initialisation
|
||||
- `0x01`: I-frame (intra-coded frame)
|
||||
- `0x12`: GOP unified packet (3D DWT mode)
|
||||
- `0x24`: Embedded TAD audio
|
||||
- `0xFC`: GOP synchronisation
|
||||
- `0xFD`: Timecode
|
||||
|
||||
## Debugging Tools
|
||||
|
||||
### TAV Inspector
|
||||
|
||||
Analyse TAV packet structure and decode individual frames:
|
||||
|
||||
```bash
|
||||
# Verbose packet analysis
|
||||
./tav_inspector input.tav -v
|
||||
|
||||
# Extract specific frame ranges
|
||||
./tav_inspector input.tav --frame-range 100-200
|
||||
```
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **TAD** (TSVM Advanced Audio): Perceptual audio codec using CDF 9/7 wavelets
|
||||
- **TSVM**: Target virtual machine platform for TAV playback
|
||||
|
||||
## Licence
|
||||
|
||||
MIT.
|
||||
@@ -1,424 +0,0 @@
|
||||
/**
|
||||
* TAV+UCF Payload Writer for TAV Files
|
||||
* Creates a TAV header-only (32 bytes) + UCF cue file (4KB) for concatenated TAV files
|
||||
* Total output size: 4096 bytes (32 + 4064)
|
||||
* Usage: ./create_ucf_payload input.tav output.ucf [track_names.txt]
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define TAV_HEADER_SIZE 32
|
||||
#define UCF_SIZE 4064
|
||||
#define TAV_OFFSET_BIAS (TAV_HEADER_SIZE + UCF_SIZE)
|
||||
#define TAV_MAGIC "\x1FTSVMTA" // Matches both TAV and TAP
|
||||
|
||||
typedef struct {
|
||||
uint8_t magic[8];
|
||||
uint8_t version;
|
||||
uint16_t width;
|
||||
uint16_t height;
|
||||
uint8_t fps;
|
||||
uint32_t total_frames;
|
||||
// ... rest of header fields
|
||||
} __attribute__((packed)) TAVHeader;
|
||||
|
||||
// Write TAV header-only payload (File Role = 1)
|
||||
static void write_tav_header_only(FILE *out) {
|
||||
uint8_t header[TAV_HEADER_SIZE] = {0};
|
||||
|
||||
// Magic: "\x1FTSVMTAV"
|
||||
header[0] = 0x1F;
|
||||
header[1] = 'T';
|
||||
header[2] = 'S';
|
||||
header[3] = 'V';
|
||||
header[4] = 'M';
|
||||
header[5] = 'T';
|
||||
header[6] = 'A';
|
||||
header[7] = 'V';
|
||||
|
||||
// Version: 5 (YCoCg-R perceptual)
|
||||
header[8] = 5;
|
||||
|
||||
// Width: 560 (little-endian)
|
||||
header[9] = 0x30;
|
||||
header[10] = 0x02;
|
||||
|
||||
// Height: 448 (little-endian)
|
||||
header[11] = 0xC0;
|
||||
header[12] = 0x01;
|
||||
|
||||
// FPS: 30
|
||||
header[13] = 30;
|
||||
|
||||
// Total Frames: 0xFFFFFFFF (still image marker / not applicable)
|
||||
header[14] = 0xFF;
|
||||
header[15] = 0xFF;
|
||||
header[16] = 0xFF;
|
||||
header[17] = 0xFF;
|
||||
|
||||
// Wavelet Filter Type: 1 (9/7 irreversible, default)
|
||||
header[18] = 1;
|
||||
|
||||
// Decomposition Levels: 6
|
||||
header[19] = 6;
|
||||
|
||||
// Quantiser Indices (Y, Co, Cg): 255 (not applicable for header-only)
|
||||
header[20] = 0xFF;
|
||||
header[21] = 0xFF;
|
||||
header[22] = 0xFF;
|
||||
|
||||
// Extra Feature Flags: 0x80 (bit 7 = has no actual packets)
|
||||
header[23] = 0x80;
|
||||
|
||||
// Video Flags: 0
|
||||
header[24] = 0;
|
||||
|
||||
// Encoder quality level: 0
|
||||
header[25] = 0;
|
||||
|
||||
// Channel layout: 0 (Y-Co-Cg)
|
||||
header[26] = 0;
|
||||
|
||||
// Reserved[4]: zeros (27-30 already initialised to 0)
|
||||
|
||||
// File Role: 1 (header-only, UCF payload follows)
|
||||
header[31] = 1;
|
||||
|
||||
fwrite(header, 1, TAV_HEADER_SIZE, out);
|
||||
}
|
||||
|
||||
// Write UCF header
|
||||
static void write_ucf_header(FILE *out, uint16_t num_cues) {
|
||||
uint8_t magic[8] = {0x1F, 'T', 'S', 'V', 'M', 'U', 'C', 'F'};
|
||||
uint8_t version = 1;
|
||||
uint32_t cue_file_size = TAV_OFFSET_BIAS;
|
||||
uint8_t reserved = 0;
|
||||
|
||||
fwrite(magic, 1, 8, out);
|
||||
fwrite(&version, 1, 1, out);
|
||||
fwrite(&num_cues, 2, 1, out);
|
||||
fwrite(&cue_file_size, 4, 1, out);
|
||||
fwrite(&reserved, 1, 1, out);
|
||||
}
|
||||
|
||||
// Write UCF cue element (internal addressing, human+machine interactable)
|
||||
static void write_cue_element(FILE *out, uint64_t offset, const char *name) {
|
||||
uint8_t addressing_mode = 0x22; // 0x20 (human) | 0x01 (machine) | 0x02 (internal)
|
||||
uint16_t name_len = strlen(name);
|
||||
|
||||
// Offset with 4KB bias
|
||||
uint64_t biased_offset = offset + TAV_OFFSET_BIAS;
|
||||
|
||||
fwrite(&addressing_mode, 1, 1, out);
|
||||
fwrite(&name_len, 2, 1, out);
|
||||
fwrite(name, 1, name_len, out);
|
||||
|
||||
// Write 48-bit (6-byte) offset
|
||||
fwrite(&biased_offset, 6, 1, out);
|
||||
}
|
||||
|
||||
// Read track names from file (newline-delimited)
|
||||
static char **read_track_names(const char *filename, int *count_out) {
|
||||
FILE *f = fopen(filename, "r");
|
||||
if (!f) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char **names = NULL;
|
||||
int count = 0;
|
||||
int capacity = 16;
|
||||
char line[256];
|
||||
|
||||
names = malloc(capacity * sizeof(char *));
|
||||
if (!names) {
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
// Remove trailing newline
|
||||
size_t len = strlen(line);
|
||||
if (len > 0 && line[len - 1] == '\n') {
|
||||
line[len - 1] = '\0';
|
||||
len--;
|
||||
}
|
||||
if (len > 0 && line[len - 1] == '\r') {
|
||||
line[len - 1] = '\0';
|
||||
len--;
|
||||
}
|
||||
|
||||
// Skip empty lines
|
||||
if (len == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expand capacity if needed
|
||||
if (count >= capacity) {
|
||||
capacity *= 2;
|
||||
char **new_names = realloc(names, capacity * sizeof(char *));
|
||||
if (!new_names) {
|
||||
// Cleanup on failure
|
||||
for (int i = 0; i < count; i++) {
|
||||
free(names[i]);
|
||||
}
|
||||
free(names);
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
names = new_names;
|
||||
}
|
||||
|
||||
// Allocate and copy name
|
||||
names[count] = strdup(line);
|
||||
if (!names[count]) {
|
||||
// Cleanup on failure
|
||||
for (int i = 0; i < count; i++) {
|
||||
free(names[i]);
|
||||
}
|
||||
free(names);
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
fclose(f);
|
||||
*count_out = count;
|
||||
return names;
|
||||
}
|
||||
|
||||
// Find all TAV headers in the file (with smart packet-wise skipping)
|
||||
static int find_tav_headers(FILE *in, uint64_t **offsets_out) {
|
||||
uint64_t *offsets = NULL;
|
||||
int count = 0;
|
||||
int capacity = 16;
|
||||
|
||||
offsets = malloc(capacity * sizeof(uint64_t));
|
||||
if (!offsets) {
|
||||
fprintf(stderr, "Error: Memory allocation failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Seek to beginning
|
||||
fseek(in, 0, SEEK_SET);
|
||||
|
||||
uint8_t magic[8];
|
||||
|
||||
while (1) {
|
||||
// Remember current position before reading
|
||||
uint64_t pos = ftell(in);
|
||||
|
||||
// Try to read magic
|
||||
if (fread(magic, 1, 8, in) != 8) {
|
||||
// End of file
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for TAV magic signature
|
||||
if (memcmp(magic, TAV_MAGIC, 7) == 0 && (magic[7] == 'V' || magic[7] == 'P')) {
|
||||
// Found TAV header
|
||||
if (count >= capacity) {
|
||||
capacity *= 2;
|
||||
uint64_t *new_offsets = realloc(offsets, capacity * sizeof(uint64_t));
|
||||
if (!new_offsets) {
|
||||
fprintf(stderr, "Error: Memory reallocation failed\n");
|
||||
free(offsets);
|
||||
return -1;
|
||||
}
|
||||
offsets = new_offsets;
|
||||
}
|
||||
|
||||
offsets[count++] = pos;
|
||||
printf("Found TAV header at offset: 0x%lX (%lu)\n", pos, pos);
|
||||
|
||||
// Skip past this header (32 bytes total)
|
||||
uint64_t packet_pos = pos + 32;
|
||||
fseek(in, packet_pos, SEEK_SET);
|
||||
|
||||
// Smart packet-wise skipping
|
||||
while (1) {
|
||||
uint8_t packet_type;
|
||||
if (fread(&packet_type, 1, 1, in) != 1) {
|
||||
// End of file
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if this is the start of next TAV file (0x1F is prohibited as packet type)
|
||||
if (packet_type == 0x1F) {
|
||||
// Rewind 1 byte to re-read as magic at the top of outer loop
|
||||
fseek(in, packet_pos, SEEK_SET);
|
||||
break;
|
||||
}
|
||||
|
||||
// printf("TAV Packet 0x%02X at 0x%lX\n", packet_type, packet_pos);
|
||||
|
||||
// Sync packets (0xFE, 0xFF) have no payload size - they're single-byte packets
|
||||
if (packet_type == 0xFE || packet_type == 0xFF) {
|
||||
packet_pos += 1;
|
||||
fseek(in, packet_pos, SEEK_SET);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read payload size (uint32, little-endian)
|
||||
uint32_t payload_size = 0;
|
||||
if (fread(&payload_size, 4, 1, in) != 1) {
|
||||
// End of file
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip packet: 1 byte (type) + 4 bytes (size) + payload_size
|
||||
packet_pos += 1 + 4 + payload_size;
|
||||
fseek(in, packet_pos, SEEK_SET);
|
||||
}
|
||||
} else {
|
||||
// Move forward by 1 byte for next search
|
||||
fseek(in, pos + 1, SEEK_SET);
|
||||
}
|
||||
}
|
||||
|
||||
*offsets_out = offsets;
|
||||
return count;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
if (argc < 3 || argc > 4) {
|
||||
fprintf(stderr, "Usage: %s <input.tav> <output.ucf> [track_names.txt]\n", argv[0]);
|
||||
fprintf(stderr, "Creates a 4KB UCF payload for concatenated TAV file\n");
|
||||
fprintf(stderr, " track_names.txt: Optional file with track names (one per line)\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *input_path = argv[1];
|
||||
const char *output_path = argv[2];
|
||||
const char *names_path = (argc == 4) ? argv[3] : NULL;
|
||||
|
||||
// Read track names if provided
|
||||
char **track_names = NULL;
|
||||
int num_names = 0;
|
||||
if (names_path) {
|
||||
track_names = read_track_names(names_path, &num_names);
|
||||
if (track_names) {
|
||||
printf("Loaded %d track name(s) from '%s'\n", num_names, names_path);
|
||||
} else {
|
||||
fprintf(stderr, "Warning: Could not read track names from '%s', using defaults\n", names_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Open input file
|
||||
FILE *in = fopen(input_path, "rb");
|
||||
if (!in) {
|
||||
fprintf(stderr, "Error: Cannot open input file '%s'\n", input_path);
|
||||
if (track_names) {
|
||||
for (int i = 0; i < num_names; i++) {
|
||||
free(track_names[i]);
|
||||
}
|
||||
free(track_names);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Find all TAV headers
|
||||
uint64_t *offsets = NULL;
|
||||
int num_tracks = find_tav_headers(in, &offsets);
|
||||
fclose(in);
|
||||
|
||||
if (num_tracks < 0) {
|
||||
fprintf(stderr, "Error: Failed to scan input file\n");
|
||||
if (track_names) {
|
||||
for (int i = 0; i < num_names; i++) {
|
||||
free(track_names[i]);
|
||||
}
|
||||
free(track_names);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (num_tracks == 0) {
|
||||
fprintf(stderr, "Error: No TAV headers found in input file\n");
|
||||
free(offsets);
|
||||
if (track_names) {
|
||||
for (int i = 0; i < num_names; i++) {
|
||||
free(track_names[i]);
|
||||
}
|
||||
free(track_names);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("\nFound %d TAV header(s)\n", num_tracks);
|
||||
|
||||
// Create output UCF file
|
||||
FILE *out = fopen(output_path, "wb");
|
||||
if (!out) {
|
||||
fprintf(stderr, "Error: Cannot create output file '%s'\n", output_path);
|
||||
free(offsets);
|
||||
if (track_names) {
|
||||
for (int i = 0; i < num_names; i++) {
|
||||
free(track_names[i]);
|
||||
}
|
||||
free(track_names);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write TAV header-only payload (File Role = 1)
|
||||
write_tav_header_only(out);
|
||||
printf("Written TAV header-only payload (%d bytes)\n", TAV_HEADER_SIZE);
|
||||
|
||||
// Write UCF header
|
||||
write_ucf_header(out, num_tracks);
|
||||
|
||||
// Write cue elements
|
||||
for (int i = 0; i < num_tracks; i++) {
|
||||
char default_name[32];
|
||||
const char *name;
|
||||
|
||||
// Use custom name if available, otherwise generate default
|
||||
if (track_names && i < num_names) {
|
||||
name = track_names[i];
|
||||
} else {
|
||||
snprintf(default_name, sizeof(default_name), "Track %d", i + 1);
|
||||
name = default_name;
|
||||
}
|
||||
|
||||
write_cue_element(out, offsets[i], name);
|
||||
printf("Written cue element: '%s' at offset 0x%lX (biased: 0x%lX)\n",
|
||||
name, offsets[i], offsets[i] + TAV_OFFSET_BIAS);
|
||||
}
|
||||
|
||||
// Get current file position
|
||||
long current_pos = ftell(out);
|
||||
|
||||
// Fill remaining space with zeros to reach TAV header + 4KB UCF
|
||||
size_t target_size = TAV_HEADER_SIZE + UCF_SIZE;
|
||||
if (current_pos < target_size) {
|
||||
size_t remaining = target_size - current_pos;
|
||||
uint8_t *zeros = calloc(remaining, 1);
|
||||
if (zeros) {
|
||||
fwrite(zeros, 1, remaining, out);
|
||||
free(zeros);
|
||||
}
|
||||
}
|
||||
|
||||
fclose(out);
|
||||
free(offsets);
|
||||
|
||||
// Clean up track names
|
||||
if (track_names) {
|
||||
for (int i = 0; i < num_names; i++) {
|
||||
free(track_names[i]);
|
||||
}
|
||||
free(track_names);
|
||||
}
|
||||
|
||||
printf("\nTAV+UCF payload created successfully: %s\n", output_path);
|
||||
printf("File size: %zu bytes (TAV header: %d + UCF: %d)\n",
|
||||
(size_t)(TAV_HEADER_SIZE + UCF_SIZE), TAV_HEADER_SIZE, UCF_SIZE);
|
||||
printf("\nTo create seekable TAV file, prepend this payload to your concatenated TAV file:\n");
|
||||
printf(" cat %s input.tav > output_seekable.tav\n", output_path);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,935 +0,0 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <zlib.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#include <getopt.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
// TVDOS Movie format constants
|
||||
#define TVDOS_MAGIC "\x1F\x54\x53\x56\x4D\x4D\x4F\x56" // "\x1FTSVM MOV"
|
||||
#define IPF_BLOCK_SIZE 12
|
||||
|
||||
// iPF1-delta opcodes
|
||||
#define SKIP_OP 0x00
|
||||
#define PATCH_OP 0x01
|
||||
#define REPEAT_OP 0x02
|
||||
#define END_OP 0xFF
|
||||
|
||||
// Video packet types
|
||||
#define IPF1_PACKET_TYPE 0x04, 0x00 // iPF Type 1 (4 + 0)
|
||||
#define IPF1_DELTA_PACKET_TYPE 0x04, 0x02 // iPF Type 1 delta
|
||||
#define SYNC_PACKET_TYPE 0xFF, 0xFF // Sync packet
|
||||
|
||||
// Audio constants
|
||||
#define MP2_SAMPLE_RATE 32000
|
||||
#define MP2_DEFAULT_PACKET_SIZE 0x240
|
||||
#define MP2_PACKET_TYPE_BASE 0x11
|
||||
|
||||
// Default values
|
||||
#define DEFAULT_WIDTH 560
|
||||
#define DEFAULT_HEIGHT 448
|
||||
#define TEMP_AUDIO_FILE "/tmp/tvdos_temp_audio.mp2"
|
||||
|
||||
typedef struct {
|
||||
char *input_file;
|
||||
char *output_file;
|
||||
int width;
|
||||
int height;
|
||||
int fps;
|
||||
int total_frames;
|
||||
double duration;
|
||||
int has_audio;
|
||||
int output_to_stdout;
|
||||
|
||||
// Internal buffers
|
||||
uint8_t *previous_ipf_frame;
|
||||
uint8_t *current_ipf_frame;
|
||||
uint8_t *delta_buffer;
|
||||
uint8_t *rgb_buffer;
|
||||
uint8_t *compressed_buffer;
|
||||
uint8_t *mp2_buffer;
|
||||
size_t frame_buffer_size;
|
||||
|
||||
// Audio handling
|
||||
FILE *mp2_file;
|
||||
int mp2_packet_size;
|
||||
int mp2_rate_index;
|
||||
size_t audio_remaining;
|
||||
int audio_frames_in_buffer;
|
||||
int target_audio_buffer_size;
|
||||
|
||||
// FFmpeg processes
|
||||
FILE *ffmpeg_video_pipe;
|
||||
FILE *ffmpeg_audio_pipe;
|
||||
|
||||
// Progress tracking
|
||||
struct timeval start_time;
|
||||
struct timeval last_progress_time;
|
||||
size_t total_output_bytes;
|
||||
|
||||
// Dithering mode
|
||||
int dither_mode;
|
||||
} encoder_config_t;
|
||||
|
||||
// CORRECTED YCoCg conversion matching Kotlin implementation
|
||||
typedef struct {
|
||||
float y, co, cg;
|
||||
} ycocg_t;
|
||||
|
||||
static ycocg_t rgb_to_ycocg_correct(uint8_t r, uint8_t g, uint8_t b, float ditherThreshold) {
|
||||
ycocg_t result;
|
||||
float rf = floor((ditherThreshold / 15.0 + r / 255.0) * 15.0) / 15.0;
|
||||
float gf = floor((ditherThreshold / 15.0 + g / 255.0) * 15.0) / 15.0;
|
||||
float bf = floor((ditherThreshold / 15.0 + b / 255.0) * 15.0) / 15.0;
|
||||
|
||||
// CORRECTED: Match Kotlin implementation exactly
|
||||
float co = rf - bf; // co = r - b [-1..1]
|
||||
float tmp = bf + co / 2.0f; // tmp = b + co/2
|
||||
float cg = gf - tmp; // cg = g - tmp [-1..1]
|
||||
float y = tmp + cg / 2.0f; // y = tmp + cg/2 [0..1]
|
||||
|
||||
result.y = y;
|
||||
result.co = co;
|
||||
result.cg = cg;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int quantise_4bit_y(float value) {
|
||||
// Y quantisation: round(y * 15)
|
||||
return (int)round(fmaxf(0.0f, fminf(15.0f, value * 15.0f)));
|
||||
}
|
||||
|
||||
static int chroma_to_four_bits(float f) {
|
||||
// CORRECTED: Match Kotlin chromaToFourBits function exactly
|
||||
// return (round(f * 8) + 7).coerceIn(0..15)
|
||||
int result = (int)round(f * 8.0f) + 7;
|
||||
return fmaxf(0, fminf(15, result));
|
||||
}
|
||||
|
||||
// Parse resolution string like "1024x768"
|
||||
static int parse_resolution(const char *res_str, int *width, int *height) {
|
||||
if (!res_str) return 0;
|
||||
return sscanf(res_str, "%dx%d", width, height) == 2;
|
||||
}
|
||||
|
||||
// Execute command and capture output
|
||||
static char *execute_command(const char *command) {
|
||||
FILE *pipe = popen(command, "r");
|
||||
if (!pipe) return NULL;
|
||||
|
||||
char *result = malloc(4096);
|
||||
size_t len = fread(result, 1, 4095, pipe);
|
||||
result[len] = '\0';
|
||||
|
||||
pclose(pipe);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get video metadata using ffprobe
|
||||
static int get_video_metadata(encoder_config_t *config) {
|
||||
char command[1024];
|
||||
char *output;
|
||||
|
||||
// Get frame count
|
||||
snprintf(command, sizeof(command),
|
||||
"ffprobe -v quiet -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 \"%s\"",
|
||||
config->input_file);
|
||||
output = execute_command(command);
|
||||
if (!output) {
|
||||
fprintf(stderr, "Failed to get frame count\n");
|
||||
return 0;
|
||||
}
|
||||
config->total_frames = atoi(output);
|
||||
free(output);
|
||||
|
||||
// Get frame rate
|
||||
snprintf(command, sizeof(command),
|
||||
"ffprobe -v quiet -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 \"%s\"",
|
||||
config->input_file);
|
||||
output = execute_command(command);
|
||||
if (!output) {
|
||||
fprintf(stderr, "Failed to get frame rate\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Parse framerate (could be "30/1" or "29.97")
|
||||
int num, den;
|
||||
if (sscanf(output, "%d/%d", &num, &den) == 2) {
|
||||
config->fps = (den > 0) ? (num / den) : 30;
|
||||
} else {
|
||||
config->fps = (int)round(atof(output));
|
||||
}
|
||||
free(output);
|
||||
|
||||
// Get duration
|
||||
snprintf(command, sizeof(command),
|
||||
"ffprobe -v quiet -show_entries format=duration -of csv=p=0 \"%s\"",
|
||||
config->input_file);
|
||||
output = execute_command(command);
|
||||
if (output) {
|
||||
config->duration = atof(output);
|
||||
free(output);
|
||||
}
|
||||
|
||||
// Check if has audio
|
||||
snprintf(command, sizeof(command),
|
||||
"ffprobe -v quiet -select_streams a:0 -show_entries stream=index -of csv=p=0 \"%s\"",
|
||||
config->input_file);
|
||||
output = execute_command(command);
|
||||
config->has_audio = (output && strlen(output) > 0 && atoi(output) >= 0);
|
||||
if (output) free(output);
|
||||
|
||||
// Validate frame count using duration if needed
|
||||
if (config->total_frames <= 0 && config->duration > 0) {
|
||||
config->total_frames = (int)(config->duration * config->fps);
|
||||
}
|
||||
|
||||
fprintf(stderr, "Video metadata:\n");
|
||||
fprintf(stderr, " Frames: %d\n", config->total_frames);
|
||||
fprintf(stderr, " FPS: %d\n", config->fps);
|
||||
fprintf(stderr, " Duration: %.2fs\n", config->duration);
|
||||
fprintf(stderr, " Audio: %s\n", config->has_audio ? "Yes" : "No");
|
||||
fprintf(stderr, " Resolution: %dx%d\n", config->width, config->height);
|
||||
|
||||
return (config->total_frames > 0 && config->fps > 0);
|
||||
}
|
||||
|
||||
// Start FFmpeg process for video conversion
|
||||
static int start_video_conversion(encoder_config_t *config) {
|
||||
char command[2048];
|
||||
snprintf(command, sizeof(command),
|
||||
"ffmpeg -i \"%s\" -f rawvideo -pix_fmt rgb24 -vf scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d -y - 2>/dev/null",
|
||||
config->input_file, config->width, config->height, config->width, config->height);
|
||||
|
||||
config->ffmpeg_video_pipe = popen(command, "r");
|
||||
return (config->ffmpeg_video_pipe != NULL);
|
||||
}
|
||||
|
||||
// Start FFmpeg process for audio conversion
|
||||
static int start_audio_conversion(encoder_config_t *config) {
|
||||
if (!config->has_audio) return 1;
|
||||
|
||||
char command[2048];
|
||||
snprintf(command, sizeof(command),
|
||||
"ffmpeg -i \"%s\" -acodec libtwolame -psymodel 4 -b:a 192k -ar %d -ac 2 -y \"%s\" 2>/dev/null",
|
||||
config->input_file, MP2_SAMPLE_RATE, TEMP_AUDIO_FILE);
|
||||
|
||||
int result = system(command);
|
||||
if (result == 0) {
|
||||
config->mp2_file = fopen(TEMP_AUDIO_FILE, "rb");
|
||||
if (config->mp2_file) {
|
||||
fseek(config->mp2_file, 0, SEEK_END);
|
||||
config->audio_remaining = ftell(config->mp2_file);
|
||||
fseek(config->mp2_file, 0, SEEK_SET);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
fprintf(stderr, "Warning: Failed to convert audio, proceeding without audio\n");
|
||||
config->has_audio = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write variable-length integer
|
||||
static void write_varint(uint8_t **ptr, uint32_t value) {
|
||||
while (value >= 0x80) {
|
||||
**ptr = (uint8_t)((value & 0x7F) | 0x80);
|
||||
(*ptr)++;
|
||||
value >>= 7;
|
||||
}
|
||||
**ptr = (uint8_t)(value & 0x7F);
|
||||
(*ptr)++;
|
||||
}
|
||||
|
||||
// Get MP2 packet size and rate index
|
||||
static int get_mp2_packet_size(uint8_t *header) {
|
||||
int bitrate_index = (header[2] >> 4) & 0xF;
|
||||
int padding_bit = (header[2] >> 1) & 0x1;
|
||||
|
||||
int bitrates[] = {0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, -1};
|
||||
int bitrate = bitrates[bitrate_index];
|
||||
|
||||
if (bitrate <= 0) return MP2_DEFAULT_PACKET_SIZE;
|
||||
|
||||
int frame_size = (144 * bitrate * 1000) / MP2_SAMPLE_RATE + padding_bit;
|
||||
return frame_size;
|
||||
}
|
||||
|
||||
static int mp2_packet_size_to_rate_index(int packet_size, int is_mono) {
|
||||
int rate_index;
|
||||
switch (packet_size) {
|
||||
case 144: rate_index = 0; break;
|
||||
case 216: rate_index = 2; break;
|
||||
case 252: rate_index = 4; break;
|
||||
case 288: rate_index = 6; break;
|
||||
case 360: rate_index = 8; break;
|
||||
case 432: rate_index = 10; break;
|
||||
case 504: rate_index = 12; break;
|
||||
case 576: rate_index = 14; break;
|
||||
case 720: rate_index = 16; break;
|
||||
case 864: rate_index = 18; break;
|
||||
case 1008: rate_index = 20; break;
|
||||
case 1152: rate_index = 22; break;
|
||||
case 1440: rate_index = 24; break;
|
||||
case 1728: rate_index = 26; break;
|
||||
default: rate_index = 14; break;
|
||||
}
|
||||
return rate_index + (is_mono ? 1 : 0);
|
||||
}
|
||||
|
||||
// Gzip compress function (instead of zlib)
|
||||
static size_t gzip_compress(uint8_t *src, size_t src_len, uint8_t *dst, size_t dst_max) {
|
||||
z_stream stream = {0};
|
||||
stream.next_in = src;
|
||||
stream.avail_in = src_len;
|
||||
stream.next_out = dst;
|
||||
stream.avail_out = dst_max;
|
||||
|
||||
// Use deflateInit2 with gzip format
|
||||
if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 15 + 16, 8, Z_DEFAULT_STRATEGY) != Z_OK) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (deflate(&stream, Z_FINISH) != Z_STREAM_END) {
|
||||
deflateEnd(&stream);
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t compressed_size = stream.total_out;
|
||||
deflateEnd(&stream);
|
||||
return compressed_size;
|
||||
}
|
||||
|
||||
// Bayer dithering kernels (4 patterns, each 4x4)
|
||||
static const float bayerKernels[4][16] = {
|
||||
{ // Pattern 0
|
||||
(0.0f + 0.5f) / 16.0f, (8.0f + 0.5f) / 16.0f, (2.0f + 0.5f) / 16.0f, (10.0f + 0.5f) / 16.0f,
|
||||
(12.0f + 0.5f) / 16.0f, (4.0f + 0.5f) / 16.0f, (14.0f + 0.5f) / 16.0f, (6.0f + 0.5f) / 16.0f,
|
||||
(3.0f + 0.5f) / 16.0f, (11.0f + 0.5f) / 16.0f, (1.0f + 0.5f) / 16.0f, (9.0f + 0.5f) / 16.0f,
|
||||
(15.0f + 0.5f) / 16.0f, (7.0f + 0.5f) / 16.0f, (13.0f + 0.5f) / 16.0f, (5.0f + 0.5f) / 16.0f
|
||||
},
|
||||
{ // Pattern 1
|
||||
(8.0f + 0.5f) / 16.0f, (2.0f + 0.5f) / 16.0f, (10.0f + 0.5f) / 16.0f, (0.0f + 0.5f) / 16.0f,
|
||||
(4.0f + 0.5f) / 16.0f, (14.0f + 0.5f) / 16.0f, (6.0f + 0.5f) / 16.0f, (12.0f + 0.5f) / 16.0f,
|
||||
(11.0f + 0.5f) / 16.0f, (1.0f + 0.5f) / 16.0f, (9.0f + 0.5f) / 16.0f, (3.0f + 0.5f) / 16.0f,
|
||||
(7.0f + 0.5f) / 16.0f, (13.0f + 0.5f) / 16.0f, (5.0f + 0.5f) / 16.0f, (15.0f + 0.5f) / 16.0f
|
||||
},
|
||||
{ // Pattern 2
|
||||
(7.0f + 0.5f) / 16.0f, (13.0f + 0.5f) / 16.0f, (5.0f + 0.5f) / 16.0f, (15.0f + 0.5f) / 16.0f,
|
||||
(8.0f + 0.5f) / 16.0f, (2.0f + 0.5f) / 16.0f, (10.0f + 0.5f) / 16.0f, (0.0f + 0.5f) / 16.0f,
|
||||
(4.0f + 0.5f) / 16.0f, (14.0f + 0.5f) / 16.0f, (6.0f + 0.5f) / 16.0f, (12.0f + 0.5f) / 16.0f,
|
||||
(11.0f + 0.5f) / 16.0f, (1.0f + 0.5f) / 16.0f, (9.0f + 0.5f) / 16.0f, (3.0f + 0.5f) / 16.0f
|
||||
},
|
||||
{ // Pattern 3
|
||||
(15.0f + 0.5f) / 16.0f, (7.0f + 0.5f) / 16.0f, (13.0f + 0.5f) / 16.0f, (5.0f + 0.5f) / 16.0f,
|
||||
(0.0f + 0.5f) / 16.0f, (8.0f + 0.5f) / 16.0f, (2.0f + 0.5f) / 16.0f, (10.0f + 0.5f) / 16.0f,
|
||||
(12.0f + 0.5f) / 16.0f, (4.0f + 0.5f) / 16.0f, (14.0f + 0.5f) / 16.0f, (6.0f + 0.5f) / 16.0f,
|
||||
(3.0f + 0.5f) / 16.0f, (11.0f + 0.5f) / 16.0f, (1.0f + 0.5f) / 16.0f, (9.0f + 0.5f) / 16.0f
|
||||
}
|
||||
};
|
||||
|
||||
// CORRECTED: Encode a 4x4 block to iPF1 format matching Kotlin implementation
|
||||
static void encode_ipf1_block_correct(uint8_t *rgb_data, int width, int height, int block_x, int block_y,
|
||||
int channels, int pattern, uint8_t *output) {
|
||||
ycocg_t pixels[16];
|
||||
int y_values[16];
|
||||
float co_values[16]; // Keep full precision for subsampling
|
||||
float cg_values[16]; // Keep full precision for subsampling
|
||||
|
||||
// Convert 4x4 block to YCoCg using corrected transform
|
||||
for (int py = 0; py < 4; py++) {
|
||||
for (int px = 0; px < 4; px++) {
|
||||
int src_x = block_x * 4 + px;
|
||||
int src_y = block_y * 4 + py;
|
||||
float t = (pattern < 0) ? 0.0f : bayerKernels[pattern % 4][4 * (py % 4) + (px % 4)];
|
||||
int idx = py * 4 + px;
|
||||
|
||||
if (src_x < width && src_y < height) {
|
||||
int pixel_offset = (src_y * width + src_x) * channels;
|
||||
uint8_t r = rgb_data[pixel_offset];
|
||||
uint8_t g = rgb_data[pixel_offset + 1];
|
||||
uint8_t b = rgb_data[pixel_offset + 2];
|
||||
pixels[idx] = rgb_to_ycocg_correct(r, g, b, t);
|
||||
} else {
|
||||
pixels[idx] = (ycocg_t){0.0f, 0.0f, 0.0f};
|
||||
}
|
||||
|
||||
y_values[idx] = quantise_4bit_y(pixels[idx].y);
|
||||
co_values[idx] = pixels[idx].co;
|
||||
cg_values[idx] = pixels[idx].cg;
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECTED: Chroma subsampling (4:2:0 for iPF1) with correct averaging
|
||||
int cos1 = chroma_to_four_bits((co_values[0] + co_values[1] + co_values[4] + co_values[5]) / 4.0f);
|
||||
int cos2 = chroma_to_four_bits((co_values[2] + co_values[3] + co_values[6] + co_values[7]) / 4.0f);
|
||||
int cos3 = chroma_to_four_bits((co_values[8] + co_values[9] + co_values[12] + co_values[13]) / 4.0f);
|
||||
int cos4 = chroma_to_four_bits((co_values[10] + co_values[11] + co_values[14] + co_values[15]) / 4.0f);
|
||||
|
||||
int cgs1 = chroma_to_four_bits((cg_values[0] + cg_values[1] + cg_values[4] + cg_values[5]) / 4.0f);
|
||||
int cgs2 = chroma_to_four_bits((cg_values[2] + cg_values[3] + cg_values[6] + cg_values[7]) / 4.0f);
|
||||
int cgs3 = chroma_to_four_bits((cg_values[8] + cg_values[9] + cg_values[12] + cg_values[13]) / 4.0f);
|
||||
int cgs4 = chroma_to_four_bits((cg_values[10] + cg_values[11] + cg_values[14] + cg_values[15]) / 4.0f);
|
||||
|
||||
// CORRECTED: Pack into iPF1 format matching Kotlin exactly
|
||||
// Co values (2 bytes): cos2|cos1, cos4|cos3
|
||||
output[0] = ((cos2 << 4) | cos1);
|
||||
output[1] = ((cos4 << 4) | cos3);
|
||||
|
||||
// Cg values (2 bytes): cgs2|cgs1, cgs4|cgs3
|
||||
output[2] = ((cgs2 << 4) | cgs1);
|
||||
output[3] = ((cgs4 << 4) | cgs3);
|
||||
|
||||
// CORRECTED: Y values (8 bytes) with correct ordering from Kotlin
|
||||
output[4] = ((y_values[1] << 4) | y_values[0]); // Y1|Y0
|
||||
output[5] = ((y_values[5] << 4) | y_values[4]); // Y5|Y4
|
||||
output[6] = ((y_values[3] << 4) | y_values[2]); // Y3|Y2
|
||||
output[7] = ((y_values[7] << 4) | y_values[6]); // Y7|Y6
|
||||
output[8] = ((y_values[9] << 4) | y_values[8]); // Y9|Y8
|
||||
output[9] = ((y_values[13] << 4) | y_values[12]); // Y13|Y12
|
||||
output[10] = ((y_values[11] << 4) | y_values[10]); // Y11|Y10
|
||||
output[11] = ((y_values[15] << 4) | y_values[14]); // Y15|Y14
|
||||
}
|
||||
|
||||
// Helper function for contrast weighting
|
||||
static double contrast_weight(int v1, int v2, int delta, int weight) {
|
||||
double avg = (v1 + v2) / 2.0;
|
||||
double contrast = (avg < 4 || avg > 11) ? 1.5 : 1.0;
|
||||
return delta * weight * contrast;
|
||||
}
|
||||
|
||||
// Check if two iPF1 blocks are significantly different
|
||||
static int is_significantly_different(uint8_t *block_a, uint8_t *block_b) {
|
||||
double score = 0.0;
|
||||
|
||||
// Co values (bytes 0-1)
|
||||
uint16_t co_a = block_a[0] | (block_a[1] << 8);
|
||||
uint16_t co_b = block_b[0] | (block_b[1] << 8);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int va = (co_a >> (i * 4)) & 0xF;
|
||||
int vb = (co_b >> (i * 4)) & 0xF;
|
||||
int delta = abs(va - vb);
|
||||
score += contrast_weight(va, vb, delta, 3);
|
||||
}
|
||||
|
||||
// Cg values (bytes 2-3)
|
||||
uint16_t cg_a = block_a[2] | (block_a[3] << 8);
|
||||
uint16_t cg_b = block_b[2] | (block_b[3] << 8);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int va = (cg_a >> (i * 4)) & 0xF;
|
||||
int vb = (cg_b >> (i * 4)) & 0xF;
|
||||
int delta = abs(va - vb);
|
||||
score += contrast_weight(va, vb, delta, 3);
|
||||
}
|
||||
|
||||
// Y values (bytes 4-11)
|
||||
for (int i = 4; i < 12; i++) {
|
||||
int byte_a = block_a[i] & 0xFF;
|
||||
int byte_b = block_b[i] & 0xFF;
|
||||
|
||||
int y_a_high = (byte_a >> 4) & 0xF;
|
||||
int y_a_low = byte_a & 0xF;
|
||||
int y_b_high = (byte_b >> 4) & 0xF;
|
||||
int y_b_low = byte_b & 0xF;
|
||||
|
||||
int delta_high = abs(y_a_high - y_b_high);
|
||||
int delta_low = abs(y_a_low - y_b_low);
|
||||
|
||||
score += contrast_weight(y_a_high, y_b_high, delta_high, 2);
|
||||
score += contrast_weight(y_a_low, y_b_low, delta_low, 2);
|
||||
}
|
||||
|
||||
return score > 4.0;
|
||||
}
|
||||
|
||||
// Encode iPF1 frame to buffer
|
||||
static void encode_ipf1_frame(uint8_t *rgb_data, int width, int height, int channels, int pattern,
|
||||
uint8_t *ipf_buffer) {
|
||||
int blocks_per_row = (width + 3) / 4;
|
||||
int blocks_per_col = (height + 3) / 4;
|
||||
|
||||
for (int block_y = 0; block_y < blocks_per_col; block_y++) {
|
||||
for (int block_x = 0; block_x < blocks_per_row; block_x++) {
|
||||
int block_index = block_y * blocks_per_row + block_x;
|
||||
uint8_t *output_block = ipf_buffer + block_index * IPF_BLOCK_SIZE;
|
||||
encode_ipf1_block_correct(rgb_data, width, height, block_x, block_y, channels, pattern, output_block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create iPF1-delta encoded frame
|
||||
static size_t encode_ipf1_delta(uint8_t *previous_frame, uint8_t *current_frame,
|
||||
int width, int height, uint8_t *delta_buffer) {
|
||||
int blocks_per_row = (width + 3) / 4;
|
||||
int blocks_per_col = (height + 3) / 4;
|
||||
int total_blocks = blocks_per_row * blocks_per_col;
|
||||
|
||||
uint8_t *output_ptr = delta_buffer;
|
||||
int skip_count = 0;
|
||||
uint8_t *patch_blocks = malloc(total_blocks * IPF_BLOCK_SIZE);
|
||||
int patch_count = 0;
|
||||
|
||||
for (int block_index = 0; block_index < total_blocks; block_index++) {
|
||||
uint8_t *prev_block = previous_frame + block_index * IPF_BLOCK_SIZE;
|
||||
uint8_t *curr_block = current_frame + block_index * IPF_BLOCK_SIZE;
|
||||
|
||||
if (is_significantly_different(prev_block, curr_block)) {
|
||||
if (skip_count > 0) {
|
||||
*output_ptr++ = SKIP_OP;
|
||||
write_varint(&output_ptr, skip_count);
|
||||
skip_count = 0;
|
||||
}
|
||||
|
||||
memcpy(patch_blocks + patch_count * IPF_BLOCK_SIZE, curr_block, IPF_BLOCK_SIZE);
|
||||
patch_count++;
|
||||
} else {
|
||||
if (patch_count > 0) {
|
||||
*output_ptr++ = PATCH_OP;
|
||||
write_varint(&output_ptr, patch_count);
|
||||
memcpy(output_ptr, patch_blocks, patch_count * IPF_BLOCK_SIZE);
|
||||
output_ptr += patch_count * IPF_BLOCK_SIZE;
|
||||
patch_count = 0;
|
||||
}
|
||||
skip_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (patch_count > 0) {
|
||||
*output_ptr++ = PATCH_OP;
|
||||
write_varint(&output_ptr, patch_count);
|
||||
memcpy(output_ptr, patch_blocks, patch_count * IPF_BLOCK_SIZE);
|
||||
output_ptr += patch_count * IPF_BLOCK_SIZE;
|
||||
}
|
||||
|
||||
*output_ptr++ = END_OP;
|
||||
|
||||
free(patch_blocks);
|
||||
return output_ptr - delta_buffer;
|
||||
}
|
||||
|
||||
// Get current time in seconds
|
||||
static double get_current_time_sec(struct timeval *tv) {
|
||||
gettimeofday(tv, NULL);
|
||||
return tv->tv_sec + tv->tv_usec / 1000000.0;
|
||||
}
|
||||
|
||||
// Display progress information similar to FFmpeg
|
||||
static void display_progress(encoder_config_t *config, int frame_num) {
|
||||
struct timeval current_time;
|
||||
double current_sec = get_current_time_sec(¤t_time);
|
||||
|
||||
// Only update progress once per second
|
||||
double last_progress_sec = config->last_progress_time.tv_sec + config->last_progress_time.tv_usec / 1000000.0;
|
||||
if (current_sec - last_progress_sec < 1.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
config->last_progress_time = current_time;
|
||||
|
||||
// Calculate timing
|
||||
double start_sec = config->start_time.tv_sec + config->start_time.tv_usec / 1000000.0;
|
||||
double elapsed_sec = current_sec - start_sec;
|
||||
double current_video_time = (double)frame_num / config->fps;
|
||||
double fps = frame_num / elapsed_sec;
|
||||
double speed = (elapsed_sec > 0) ? current_video_time / elapsed_sec : 0.0;
|
||||
double bitrate = (elapsed_sec > 0) ? (config->total_output_bytes * 8.0 / 1024.0) / elapsed_sec : 0.0;
|
||||
|
||||
// Format output size in human readable format
|
||||
char size_str[32];
|
||||
if (config->total_output_bytes >= 1024 * 1024) {
|
||||
snprintf(size_str, sizeof(size_str), "%.1fMB", config->total_output_bytes / (1024.0 * 1024.0));
|
||||
} else if (config->total_output_bytes >= 1024) {
|
||||
snprintf(size_str, sizeof(size_str), "%.1fkB", config->total_output_bytes / 1024.0);
|
||||
} else {
|
||||
snprintf(size_str, sizeof(size_str), "%zuB", config->total_output_bytes);
|
||||
}
|
||||
|
||||
// Format current time as HH:MM:SS.xx
|
||||
int hours = (int)(current_video_time / 3600);
|
||||
int minutes = (int)((current_video_time - hours * 3600) / 60);
|
||||
double seconds = current_video_time - hours * 3600 - minutes * 60;
|
||||
|
||||
// Print progress line (overwrite previous line)
|
||||
fprintf(stderr, "\rframe=%d fps=%.1f size=%s time=%02d:%02d:%05.2f bitrate=%.1fkbits/s speed=%4.2fx",
|
||||
frame_num, fps, size_str, hours, minutes, seconds, bitrate, speed);
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
// Process audio for current frame
|
||||
static int process_audio(encoder_config_t *config, int frame_num, FILE *output) {
|
||||
if (!config->has_audio || !config->mp2_file || config->audio_remaining <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Initialise packet size on first frame
|
||||
if (config->mp2_packet_size == 0) {
|
||||
uint8_t header[4];
|
||||
if (fread(header, 1, 4, config->mp2_file) != 4) return 1;
|
||||
fseek(config->mp2_file, 0, SEEK_SET);
|
||||
|
||||
config->mp2_packet_size = get_mp2_packet_size(header);
|
||||
int is_mono = (header[3] >> 6) == 3;
|
||||
config->mp2_rate_index = mp2_packet_size_to_rate_index(config->mp2_packet_size, is_mono);
|
||||
}
|
||||
|
||||
// Calculate how much audio time each frame represents (in seconds)
|
||||
double frame_audio_time = 1.0 / config->fps;
|
||||
|
||||
// Calculate how much audio time each MP2 packet represents
|
||||
// MP2 frame contains 1152 samples at 32kHz = 0.036 seconds
|
||||
double packet_audio_time = 1152.0 / MP2_SAMPLE_RATE;
|
||||
|
||||
// Estimate how many packets we consume per video frame
|
||||
double packets_per_frame = frame_audio_time / packet_audio_time;
|
||||
|
||||
// Only insert audio when buffer would go below 2 frames
|
||||
// Initialise with 2 packets on first frame to prime the buffer
|
||||
int packets_to_insert = 0;
|
||||
if (frame_num == 1) {
|
||||
packets_to_insert = 2;
|
||||
config->audio_frames_in_buffer = 2;
|
||||
} else {
|
||||
// Simulate buffer consumption (packets consumed per frame)
|
||||
config->audio_frames_in_buffer -= (int)ceil(packets_per_frame);
|
||||
|
||||
// Only insert packets when buffer gets low (≤ 2 frames)
|
||||
if (config->audio_frames_in_buffer <= 2) {
|
||||
packets_to_insert = config->target_audio_buffer_size - config->audio_frames_in_buffer;
|
||||
packets_to_insert = (packets_to_insert > 0) ? packets_to_insert : 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the calculated number of audio packets
|
||||
for (int q = 0; q < packets_to_insert; q++) {
|
||||
size_t bytes_to_read = config->mp2_packet_size;
|
||||
if (bytes_to_read > config->audio_remaining) {
|
||||
bytes_to_read = config->audio_remaining;
|
||||
}
|
||||
|
||||
size_t bytes_read = fread(config->mp2_buffer, 1, bytes_to_read, config->mp2_file);
|
||||
if (bytes_read == 0) break;
|
||||
|
||||
uint8_t audio_packet_type[2] = {config->mp2_rate_index, MP2_PACKET_TYPE_BASE};
|
||||
fwrite(audio_packet_type, 1, 2, output);
|
||||
fwrite(config->mp2_buffer, 1, bytes_read, output);
|
||||
|
||||
// Track audio bytes written
|
||||
config->total_output_bytes += 2 + bytes_read;
|
||||
config->audio_remaining -= bytes_read;
|
||||
config->audio_frames_in_buffer++;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write TVDOS header
|
||||
static void write_tvdos_header(encoder_config_t *config, FILE *output) {
|
||||
fwrite(TVDOS_MAGIC, 1, 8, output);
|
||||
fwrite(&config->width, 2, 1, output);
|
||||
fwrite(&config->height, 2, 1, output);
|
||||
fwrite(&config->fps, 2, 1, output);
|
||||
fwrite(&config->total_frames, 4, 1, output);
|
||||
|
||||
uint16_t unused = 0x00FF;
|
||||
fwrite(&unused, 2, 1, output);
|
||||
|
||||
int audio_sample_size = 2 * (((MP2_SAMPLE_RATE / config->fps) + 1));
|
||||
int audio_queue_size = config->has_audio ?
|
||||
(int)ceil(audio_sample_size / 2304.0) + 1 : 0;
|
||||
|
||||
uint16_t audio_queue_info = config->has_audio ?
|
||||
(MP2_DEFAULT_PACKET_SIZE >> 2) | (audio_queue_size << 12) : 0x0000;
|
||||
fwrite(&audio_queue_info, 2, 1, output);
|
||||
|
||||
// Store target buffer size for audio timing
|
||||
config->target_audio_buffer_size = audio_queue_size;
|
||||
|
||||
uint8_t reserved[10] = {0};
|
||||
fwrite(reserved, 1, 10, output);
|
||||
}
|
||||
|
||||
// Initialise encoder configuration
|
||||
static encoder_config_t *init_encoder_config() {
|
||||
encoder_config_t *config = calloc(1, sizeof(encoder_config_t));
|
||||
if (!config) return NULL;
|
||||
|
||||
config->width = DEFAULT_WIDTH;
|
||||
config->height = DEFAULT_HEIGHT;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Allocate encoder buffers
|
||||
static int allocate_buffers(encoder_config_t *config) {
|
||||
config->frame_buffer_size = ((config->width + 3) / 4) * ((config->height + 3) / 4) * IPF_BLOCK_SIZE;
|
||||
|
||||
config->rgb_buffer = malloc(config->width * config->height * 3);
|
||||
config->previous_ipf_frame = malloc(config->frame_buffer_size);
|
||||
config->current_ipf_frame = malloc(config->frame_buffer_size);
|
||||
config->delta_buffer = malloc(config->frame_buffer_size * 2);
|
||||
config->compressed_buffer = malloc(config->frame_buffer_size * 2);
|
||||
config->mp2_buffer = malloc(2048);
|
||||
|
||||
return (config->rgb_buffer && config->previous_ipf_frame &&
|
||||
config->current_ipf_frame && config->delta_buffer &&
|
||||
config->compressed_buffer && config->mp2_buffer);
|
||||
}
|
||||
|
||||
// Process one frame - CORRECTED ORDER: Audio -> Video -> Sync
|
||||
static int process_frame(encoder_config_t *config, int frame_num, int is_keyframe, FILE *output) {
|
||||
// Read RGB data from FFmpeg pipe first
|
||||
size_t rgb_size = config->width * config->height * 3;
|
||||
if (fread(config->rgb_buffer, 1, rgb_size, config->ffmpeg_video_pipe) != rgb_size) {
|
||||
if (feof(config->ffmpeg_video_pipe)) return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Step 1: Process audio FIRST (matches working file pattern)
|
||||
if (!process_audio(config, frame_num, output)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Step 2: Encode and write video
|
||||
int pattern;
|
||||
switch (config->dither_mode) {
|
||||
case 0: pattern = -1; break; // No dithering
|
||||
case 1: pattern = 0; break; // Static pattern
|
||||
case 2: pattern = frame_num % 4; break; // Dynamic pattern
|
||||
default: pattern = 0; break; // Fallback to static
|
||||
}
|
||||
encode_ipf1_frame(config->rgb_buffer, config->width, config->height, 3, pattern,
|
||||
config->current_ipf_frame);
|
||||
|
||||
// Determine if we should use delta encoding
|
||||
int use_delta = 0;
|
||||
size_t data_size = config->frame_buffer_size;
|
||||
uint8_t *frame_data = config->current_ipf_frame;
|
||||
|
||||
if (frame_num > 1 && !is_keyframe) {
|
||||
size_t delta_size = encode_ipf1_delta(config->previous_ipf_frame,
|
||||
config->current_ipf_frame,
|
||||
config->width, config->height,
|
||||
config->delta_buffer);
|
||||
|
||||
if (delta_size < config->frame_buffer_size * 0.576) {
|
||||
use_delta = 1;
|
||||
data_size = delta_size;
|
||||
frame_data = config->delta_buffer;
|
||||
}
|
||||
}
|
||||
|
||||
// Compress the frame data using gzip
|
||||
size_t compressed_size = gzip_compress(frame_data, data_size,
|
||||
config->compressed_buffer,
|
||||
config->frame_buffer_size * 2);
|
||||
if (compressed_size == 0) {
|
||||
fprintf(stderr, "Gzip compression failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Write video packet
|
||||
if (use_delta) {
|
||||
uint8_t packet_type[2] = {IPF1_DELTA_PACKET_TYPE};
|
||||
fwrite(packet_type, 1, 2, output);
|
||||
} else {
|
||||
uint8_t packet_type[2] = {IPF1_PACKET_TYPE};
|
||||
fwrite(packet_type, 1, 2, output);
|
||||
}
|
||||
|
||||
uint32_t size_le = compressed_size;
|
||||
fwrite(&size_le, 4, 1, output);
|
||||
fwrite(config->compressed_buffer, 1, compressed_size, output);
|
||||
|
||||
// Step 3: Write sync packet AFTER video (matches working file pattern)
|
||||
uint8_t sync[2] = {SYNC_PACKET_TYPE};
|
||||
fwrite(sync, 1, 2, output);
|
||||
|
||||
// Track video bytes written (packet type + size + compressed data + sync)
|
||||
config->total_output_bytes += 2 + 4 + compressed_size + 2;
|
||||
|
||||
// Swap frame buffers
|
||||
uint8_t *temp = config->previous_ipf_frame;
|
||||
config->previous_ipf_frame = config->current_ipf_frame;
|
||||
config->current_ipf_frame = temp;
|
||||
|
||||
// Display progress
|
||||
display_progress(config, frame_num);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
static void cleanup_config(encoder_config_t *config) {
|
||||
if (!config) return;
|
||||
|
||||
if (config->ffmpeg_video_pipe) pclose(config->ffmpeg_video_pipe);
|
||||
if (config->mp2_file) fclose(config->mp2_file);
|
||||
|
||||
free(config->input_file);
|
||||
free(config->output_file);
|
||||
free(config->rgb_buffer);
|
||||
free(config->previous_ipf_frame);
|
||||
free(config->current_ipf_frame);
|
||||
free(config->delta_buffer);
|
||||
free(config->compressed_buffer);
|
||||
free(config->mp2_buffer);
|
||||
|
||||
// Remove temporary audio file
|
||||
unlink(TEMP_AUDIO_FILE);
|
||||
|
||||
free(config);
|
||||
}
|
||||
|
||||
// Print usage information
|
||||
static void print_usage(const char *program_name) {
|
||||
printf("TVDOS Movie Encoder\n\n");
|
||||
printf("Usage: %s [options] input_video\n\n", program_name);
|
||||
printf("Options:\n");
|
||||
printf(" -o, --output FILE Output TVDOS movie file (default: stdout)\n");
|
||||
printf(" -s, --size WxH Video resolution (default: 560x448)\n");
|
||||
printf(" -d, --dither MODE Dithering mode (default: 1)\n");
|
||||
printf(" 0: No dithering\n");
|
||||
printf(" 1: Static pattern\n");
|
||||
printf(" 2: Dynamic pattern (better quality, larger files)\n");
|
||||
printf(" -h, --help Show this help message\n\n");
|
||||
printf("Examples:\n");
|
||||
printf(" %s input.mp4 -o output.mov\n", program_name);
|
||||
printf(" %s input.avi -s 1024x768 -o output.mov\n", program_name);
|
||||
printf(" yt-dlp -o - \"https://youtube.com/watch?v=VIDEO_ID\" | ffmpeg -i pipe:0 -c copy temp.mp4 && %s temp.mp4 -o youtube_video.mov && rm temp.mp4\n", program_name);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
encoder_config_t *config = init_encoder_config();
|
||||
if (!config) {
|
||||
fprintf(stderr, "Failed to initialise encoder\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
config->output_to_stdout = 1; // Default to stdout
|
||||
config->dither_mode = 1; // Default to static dithering
|
||||
|
||||
// Parse command line arguments
|
||||
static struct option long_options[] = {
|
||||
{"output", required_argument, 0, 'o'},
|
||||
{"size", required_argument, 0, 's'},
|
||||
{"dither", required_argument, 0, 'd'},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
int c;
|
||||
while ((c = getopt_long(argc, argv, "o:s:d:h", long_options, NULL)) != -1) {
|
||||
switch (c) {
|
||||
case 'o':
|
||||
config->output_file = strdup(optarg);
|
||||
config->output_to_stdout = 0;
|
||||
break;
|
||||
case 's':
|
||||
if (!parse_resolution(optarg, &config->width, &config->height)) {
|
||||
fprintf(stderr, "Invalid resolution format: %s\n", optarg);
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
break;
|
||||
case 'd':
|
||||
config->dither_mode = atoi(optarg);
|
||||
if (config->dither_mode < 0 || config->dither_mode > 2) {
|
||||
fprintf(stderr, "Invalid dither mode: %s (must be 0, 1, or 2)\n", optarg);
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
break;
|
||||
case 'h':
|
||||
print_usage(argv[0]);
|
||||
cleanup_config(config);
|
||||
return 0;
|
||||
default:
|
||||
print_usage(argv[0]);
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (optind >= argc) {
|
||||
fprintf(stderr, "Error: Input video file required\n\n");
|
||||
print_usage(argv[0]);
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
config->input_file = strdup(argv[optind]);
|
||||
|
||||
// Get video metadata
|
||||
if (!get_video_metadata(config)) {
|
||||
fprintf(stderr, "Failed to analyze video metadata\n");
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Allocate buffers
|
||||
if (!allocate_buffers(config)) {
|
||||
fprintf(stderr, "Failed to allocate memory buffers\n");
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Start video conversion
|
||||
if (!start_video_conversion(config)) {
|
||||
fprintf(stderr, "Failed to start video conversion\n");
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Start audio conversion
|
||||
if (!start_audio_conversion(config)) {
|
||||
fprintf(stderr, "Failed to start audio conversion\n");
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Open output
|
||||
FILE *output = config->output_to_stdout ? stdout : fopen(config->output_file, "wb");
|
||||
if (!output) {
|
||||
fprintf(stderr, "Failed to open output file\n");
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write TVDOS header
|
||||
write_tvdos_header(config, output);
|
||||
|
||||
// Initialise progress tracking
|
||||
gettimeofday(&config->start_time, NULL);
|
||||
config->last_progress_time = config->start_time;
|
||||
config->total_output_bytes = 8 + 2 + 2 + 2 + 4 + 2 + 2 + 10; // TVDOS header size
|
||||
|
||||
// Process frames with correct order: Audio -> Video -> Sync
|
||||
for (int frame = 1; frame <= config->total_frames; frame++) {
|
||||
int is_keyframe = (frame == 1) || (frame % 30 == 0);
|
||||
|
||||
int result = process_frame(config, frame, is_keyframe, output);
|
||||
if (result <= 0) {
|
||||
if (result == 0) {
|
||||
fprintf(stderr, "End of video at frame %d\n", frame);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress update and newline
|
||||
fprintf(stderr, "\n");
|
||||
|
||||
if (!config->output_to_stdout) {
|
||||
fclose(output);
|
||||
fprintf(stderr, "Encoding complete: %s\n", config->output_file);
|
||||
}
|
||||
|
||||
cleanup_config(config);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// Created by CuriousTorvald and Claude on 2025-10-17
|
||||
// MPEG-style bidirectional block motion compensation for TAV encoder
|
||||
// Simplified: Single-level diamond search, variable blocks, overlaps, sub-pixel refinement
|
||||
|
||||
#include <opencv2/opencv.hpp>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
|
||||
extern "C" {
|
||||
|
||||
// Dense optical flow estimation using Farneback algorithm
|
||||
// Computes flow at every pixel, then samples at block centers for motion vectors
|
||||
// Much more spatially coherent than independent block matching
|
||||
void estimate_optical_flow_motion(
|
||||
const float *current_y, // Current frame Y channel (width×height)
|
||||
const float *reference_y, // Reference frame Y channel
|
||||
int width, int height,
|
||||
int block_size, // Block size (e.g., 16)
|
||||
int16_t *mvs_x, // Output: motion vectors X (in 1/4-pixel units)
|
||||
int16_t *mvs_y // Output: motion vectors Y (in 1/4-pixel units)
|
||||
) {
|
||||
// Convert float Y channels to 8-bit grayscale for OpenCV
|
||||
cv::Mat cur_gray(height, width, CV_8UC1);
|
||||
cv::Mat ref_gray(height, width, CV_8UC1);
|
||||
|
||||
// Detect if Y is in [0,1] range and scale to [0,255] if needed
|
||||
float y_min = current_y[0], y_max = current_y[0];
|
||||
for (int i = 1; i < width * height; i++) {
|
||||
if (current_y[i] < y_min) y_min = current_y[i];
|
||||
if (current_y[i] > y_max) y_max = current_y[i];
|
||||
}
|
||||
float scale = (y_max <= 1.1f) ? 255.0f : 1.0f;
|
||||
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int idx = y * width + x;
|
||||
cur_gray.at<uint8_t>(y, x) = (uint8_t)std::round(std::max(0.0f, std::min(255.0f, current_y[idx] * scale)));
|
||||
ref_gray.at<uint8_t>(y, x) = (uint8_t)std::round(std::max(0.0f, std::min(255.0f, reference_y[idx] * scale)));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute dense optical flow using Farneback algorithm
|
||||
// IMPORTANT: We need BACKWARD flow (current → reference) for motion compensation
|
||||
// This tells us where to PULL pixels FROM in the reference frame
|
||||
cv::Mat flow;
|
||||
cv::calcOpticalFlowFarneback(
|
||||
cur_gray, // Current frame (source)
|
||||
ref_gray, // Reference frame (destination)
|
||||
flow, // Output flow (2-channel float: dx, dy per pixel)
|
||||
0.5, // pyr_scale: pyramid scale (0.5 = each layer is half size)
|
||||
3, // levels: number of pyramid levels
|
||||
20, // winsize: averaging window size
|
||||
3, // iterations: number of iterations at each pyramid level
|
||||
5, // poly_n: size of pixel neighborhood (5 or 7)
|
||||
1.2, // poly_sigma: standard deviation of Gaussian for polynomial expansion
|
||||
0 // flags: 0 = normal, OPTFLOW_USE_INITIAL_FLOW = use input flow as initial estimate
|
||||
);
|
||||
|
||||
// Sample flow at block centers to get motion vectors
|
||||
int num_blocks_x = (width + block_size - 1) / block_size;
|
||||
int num_blocks_y = (height + block_size - 1) / block_size;
|
||||
|
||||
for (int by = 0; by < num_blocks_y; by++) {
|
||||
for (int bx = 0; bx < num_blocks_x; bx++) {
|
||||
int block_idx = by * num_blocks_x + bx;
|
||||
|
||||
// Block center position
|
||||
int center_x = bx * block_size + block_size / 2;
|
||||
int center_y = by * block_size + block_size / 2;
|
||||
|
||||
// Clamp to frame boundaries
|
||||
if (center_x >= width) center_x = width - 1;
|
||||
if (center_y >= height) center_y = height - 1;
|
||||
|
||||
// Get flow at block center
|
||||
cv::Point2f flow_vec = flow.at<cv::Point2f>(center_y, center_x);
|
||||
|
||||
// Convert to 1/4-pixel units and store
|
||||
// Flow is in pixels, positive = motion to the right/down
|
||||
mvs_x[block_idx] = (int16_t)std::round(flow_vec.x * 4.0f);
|
||||
mvs_y[block_idx] = (int16_t)std::round(flow_vec.y * 4.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Block-based motion compensation with bilinear interpolation (sub-pixel precision)
|
||||
// MVs are in 1/4-pixel units
|
||||
// This implements the warp() function from MC-EZBC pseudocode
|
||||
void warp_block_motion(
|
||||
const float *src, // Source frame
|
||||
int width, int height,
|
||||
const int16_t *mvs_x, // Motion vectors X (1/4-pixel units)
|
||||
const int16_t *mvs_y, // Motion vectors Y (1/4-pixel units)
|
||||
int block_size, // Block size (e.g., 16)
|
||||
float *dst // Output warped frame
|
||||
) {
|
||||
int num_blocks_x = (width + block_size - 1) / block_size;
|
||||
int num_blocks_y = (height + block_size - 1) / block_size;
|
||||
|
||||
// Process each block
|
||||
for (int by = 0; by < num_blocks_y; by++) {
|
||||
for (int bx = 0; bx < num_blocks_x; bx++) {
|
||||
int block_idx = by * num_blocks_x + bx;
|
||||
|
||||
// Get motion vector for this block (in 1/4-pixel units)
|
||||
float mv_x = mvs_x[block_idx] / 4.0f; // Convert to pixels
|
||||
float mv_y = mvs_y[block_idx] / 4.0f;
|
||||
|
||||
// Block boundaries in destination frame
|
||||
int block_x_start = bx * block_size;
|
||||
int block_y_start = by * block_size;
|
||||
int block_x_end = std::min(block_x_start + block_size, width);
|
||||
int block_y_end = std::min(block_y_start + block_size, height);
|
||||
|
||||
// Warp each pixel in the block
|
||||
for (int y = block_y_start; y < block_y_end; y++) {
|
||||
for (int x = block_x_start; x < block_x_end; x++) {
|
||||
// Source position (backward warping)
|
||||
float src_x = x - mv_x;
|
||||
float src_y = y - mv_y;
|
||||
|
||||
// Clamp to valid range
|
||||
src_x = std::max(0.0f, std::min((float)(width - 1), src_x));
|
||||
src_y = std::max(0.0f, std::min((float)(height - 1), src_y));
|
||||
|
||||
// Bilinear interpolation
|
||||
int x0 = (int)src_x;
|
||||
int y0 = (int)src_y;
|
||||
int x1 = std::min(x0 + 1, width - 1);
|
||||
int y1 = std::min(y0 + 1, height - 1);
|
||||
|
||||
float fx = src_x - x0;
|
||||
float fy = src_y - y0;
|
||||
|
||||
float val00 = src[y0 * width + x0];
|
||||
float val10 = src[y0 * width + x1];
|
||||
float val01 = src[y1 * width + x0];
|
||||
float val11 = src[y1 * width + x1];
|
||||
|
||||
float val_top = (1.0f - fx) * val00 + fx * val10;
|
||||
float val_bot = (1.0f - fx) * val01 + fx * val11;
|
||||
float val = (1.0f - fy) * val_top + fy * val_bot;
|
||||
|
||||
dst[y * width + x] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bidirectional motion compensation for MC-EZBC predict step
|
||||
// Implements: prediction = 0.5 * (warp(f0, MV_fwd) + warp(f1, MV_bwd))
|
||||
void warp_bidirectional(
|
||||
const float *f0, const float *f1,
|
||||
int width, int height,
|
||||
const int16_t *mvs_fwd_x, const int16_t *mvs_fwd_y, // F0 → F1
|
||||
const int16_t *mvs_bwd_x, const int16_t *mvs_bwd_y, // F1 → F0
|
||||
int block_size,
|
||||
float *prediction // Output: 0.5 * (warped_f0 + warped_f1)
|
||||
) {
|
||||
int num_pixels = width * height;
|
||||
|
||||
// Allocate temporary buffers
|
||||
float *warped_f0 = new float[num_pixels];
|
||||
float *warped_f1 = new float[num_pixels];
|
||||
|
||||
// Warp f0 forward using forward MVs
|
||||
warp_block_motion(f0, width, height, mvs_fwd_x, mvs_fwd_y, block_size, warped_f0);
|
||||
|
||||
// Warp f1 backward using backward MVs
|
||||
warp_block_motion(f1, width, height, mvs_bwd_x, mvs_bwd_y, block_size, warped_f1);
|
||||
|
||||
// Average the two warped frames
|
||||
for (int i = 0; i < num_pixels; i++) {
|
||||
prediction[i] = 0.5f * (warped_f0[i] + warped_f1[i]);
|
||||
}
|
||||
|
||||
delete[] warped_f0;
|
||||
delete[] warped_f1;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -1,795 +0,0 @@
|
||||
/*
|
||||
encoder_tav_text.c
|
||||
Text-based video encoder for TSVM using custom font ROMs
|
||||
|
||||
Outputs Videotex files with custom header and packet type 0x3F (text mode)
|
||||
|
||||
File structure:
|
||||
- Videotex header (32 bytes): magic "\x1FTSVM-VT", version, grid dims, fps, total_frames
|
||||
- Extended header packet (0xEF): BGNT, ENDT, CDAT, VNDR, FMPG
|
||||
- Font ROM packets (0x30): lowrom and highrom (1920 bytes each)
|
||||
- Per-frame sequence: [audio 0x20], [timecode 0xFD], [videotex 0x3F], [sync 0xFF]
|
||||
|
||||
Videotex packet structure (0x3F): Zstd([rows][cols][fg-array][bg-array][char-array])
|
||||
- rows: uint8 (32)
|
||||
- cols: uint8 (80)
|
||||
- fg-array: rows*cols bytes (foreground colors, 0xF0=black, 0xFE=white)
|
||||
- bg-array: rows*cols bytes (background colors, 0xF0=black, 0xFE=white)
|
||||
- char-array: rows*cols bytes (glyph indices 0-255)
|
||||
|
||||
Total uncompressed size: 2 + (80*32*3) = 7682 bytes
|
||||
Separated arrays compress much better (fg/bg are just 0xF0/0xFE runs)
|
||||
Video size: 80×32 characters (560×448 pixels with 7×14 font)
|
||||
Audio: MP2 encoding at 96 kbps, 32 KHz stereo (packet 0x20)
|
||||
Each text frame is treated as an I-frame with sync packet
|
||||
|
||||
Usage:
|
||||
gcc -Ofast -std=c11 -Wall encoder_tav_text.c -o encoder_tav_text -lm -lzstd
|
||||
./encoder_tav_text -i video.mp4 -f font.chr -o output.mv3
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <zstd.h>
|
||||
#include <unistd.h>
|
||||
#include <time.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#define ENCODER_VENDOR_STRING "Encoder-TAV-Text 20251121 (videotex)"
|
||||
|
||||
#define CHAR_W 7
|
||||
#define CHAR_H 14
|
||||
#define GRID_W 80
|
||||
#define GRID_H 32
|
||||
#define PIXEL_W (GRID_W * CHAR_W) // 560
|
||||
#define PIXEL_H (GRID_H * CHAR_H) // 448
|
||||
#define PATCH_SZ (CHAR_W * CHAR_H)
|
||||
#define SAMPLE_RATE 32000
|
||||
#define MP2_DEFAULT_PACKET_SIZE 1152
|
||||
|
||||
// TAV packet types
|
||||
#define PACKET_TIMECODE 0xFD
|
||||
#define PACKET_SYNC 0xFF
|
||||
#define PACKET_AUDIO_MP2 0x20
|
||||
#define PACKET_SSF 0x30
|
||||
#define PACKET_TEXT 0x3F
|
||||
#define PACKET_EXTENDED_HDR 0xEF
|
||||
|
||||
// SSF opcodes for font ROM
|
||||
#define SSF_OPCODE_LOWROM 0x80
|
||||
#define SSF_OPCODE_HIGHROM 0x81
|
||||
|
||||
// Font ROM size constants
|
||||
#define FONTROM_PADDED_SIZE 1920
|
||||
#define GLYPHS_PER_ROM 128
|
||||
|
||||
// Color mapping (4-bit RGB to TSVM palette)
|
||||
#define COLOR_BLACK 0xF0
|
||||
#define COLOR_WHITE 0xFE
|
||||
|
||||
// Generate random filename for temporary audio file
|
||||
static void generate_random_filename(char *filename) {
|
||||
srand(time(NULL));
|
||||
|
||||
const char charset[] = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const int charset_size = sizeof(charset) - 1;
|
||||
|
||||
// Start with the prefix
|
||||
strcpy(filename, "/tmp/");
|
||||
|
||||
// Generate 32 random characters
|
||||
for (int i = 0; i < 32; i++) {
|
||||
filename[5 + i] = charset[rand() % charset_size];
|
||||
}
|
||||
|
||||
// Add the .mp2 extension
|
||||
strcpy(filename + 37, ".mp2");
|
||||
filename[41] = '\0'; // Null terminate
|
||||
}
|
||||
|
||||
char TEMP_AUDIO_FILE[42];
|
||||
|
||||
// Global flag to disable inverted character matching
|
||||
int g_no_invert_char = 0;
|
||||
|
||||
typedef struct {
|
||||
uint8_t *data; // Binary glyph data (PATCH_SZ bytes per glyph)
|
||||
int count; // Number of glyphs
|
||||
} FontROM;
|
||||
|
||||
// Get FFmpeg version string
|
||||
char *get_ffmpeg_version(void) {
|
||||
FILE *pipe = popen("ffmpeg -version 2>&1 | head -1", "r");
|
||||
if (!pipe) return NULL;
|
||||
|
||||
char *version = malloc(256);
|
||||
if (!version) {
|
||||
pclose(pipe);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (fgets(version, 256, pipe)) {
|
||||
// Remove trailing newline
|
||||
size_t len = strlen(version);
|
||||
if (len > 0 && version[len - 1] == '\n') {
|
||||
version[len - 1] = '\0';
|
||||
}
|
||||
pclose(pipe);
|
||||
return version;
|
||||
}
|
||||
|
||||
free(version);
|
||||
pclose(pipe);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Detect video FPS using ffprobe
|
||||
float detect_fps(const char *video_path) {
|
||||
char cmd[1024];
|
||||
snprintf(cmd, sizeof(cmd),
|
||||
"ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate "
|
||||
"-of default=noprint_wrappers=1:nokey=1 \"%s\" 2>/dev/null",
|
||||
video_path);
|
||||
|
||||
FILE *pipe = popen(cmd, "r");
|
||||
if (!pipe) return 30.0f; // fallback
|
||||
|
||||
char fps_str[64] = {0};
|
||||
if (fgets(fps_str, sizeof(fps_str), pipe)) {
|
||||
// Parse fraction like "30/1" or "24000/1001"
|
||||
int num = 0, den = 1;
|
||||
if (sscanf(fps_str, "%d/%d", &num, &den) == 2 && den > 0) {
|
||||
pclose(pipe);
|
||||
return (float)num / (float)den;
|
||||
}
|
||||
}
|
||||
pclose(pipe);
|
||||
return 30.0f; // fallback
|
||||
}
|
||||
|
||||
// Load font ROM (14 bytes per glyph, no header)
|
||||
FontROM *load_font_rom(const char *path) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) return NULL;
|
||||
|
||||
fseek(f, 0, SEEK_END);
|
||||
long size = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
|
||||
if (size % 14 != 0) {
|
||||
fprintf(stderr, "Warning: ROM size not divisible by 14 (got %ld bytes)\n", size);
|
||||
}
|
||||
|
||||
int glyph_count = size / 14;
|
||||
FontROM *rom = malloc(sizeof(FontROM));
|
||||
rom->count = glyph_count;
|
||||
rom->data = malloc(glyph_count * PATCH_SZ);
|
||||
|
||||
// Read and unpack glyphs
|
||||
for (int g = 0; g < glyph_count; g++) {
|
||||
uint8_t row_bytes[14];
|
||||
if (fread(row_bytes, 14, 1, f) != 1) {
|
||||
free(rom->data);
|
||||
free(rom);
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Unpack bits to binary pixels
|
||||
for (int row = 0; row < CHAR_H; row++) {
|
||||
for (int col = 0; col < CHAR_W; col++) {
|
||||
// Bit 6 = leftmost, bit 0 = rightmost
|
||||
int bit = (row_bytes[row] >> (6 - col)) & 1;
|
||||
rom->data[g * PATCH_SZ + row * CHAR_W + col] = bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose(f);
|
||||
fprintf(stderr, "Loaded font ROM: %d glyphs\n", glyph_count);
|
||||
return rom;
|
||||
}
|
||||
|
||||
// Find best matching glyph for a grayscale patch
|
||||
int find_best_glyph(const uint8_t *patch, const FontROM *rom, uint8_t *out_bg, uint8_t *out_fg) {
|
||||
// Try both normal and inverted matching (unless --no-invert-char is set)
|
||||
int best_glyph = 0;
|
||||
float best_error = INFINITY;
|
||||
uint8_t best_bg = COLOR_BLACK, best_fg = COLOR_WHITE;
|
||||
|
||||
for (int g = 0; g < rom->count; g++) {
|
||||
const uint8_t *glyph = &rom->data[g * PATCH_SZ];
|
||||
|
||||
// Try normal: glyph 1 = fg, glyph 0 = bg
|
||||
float err_normal = 0;
|
||||
for (int i = 0; i < PATCH_SZ; i++) {
|
||||
int expected = glyph[i] ? 255 : 0;
|
||||
int diff = patch[i] - expected;
|
||||
err_normal += diff * diff;
|
||||
}
|
||||
|
||||
if (err_normal < best_error) {
|
||||
best_error = err_normal;
|
||||
best_glyph = g;
|
||||
best_bg = COLOR_BLACK;
|
||||
best_fg = COLOR_WHITE;
|
||||
}
|
||||
|
||||
// Try inverted: glyph 0 = fg, glyph 1 = bg (skip if --no-invert-char)
|
||||
if (!g_no_invert_char) {
|
||||
float err_inverted = 0;
|
||||
for (int i = 0; i < PATCH_SZ; i++) {
|
||||
int expected = glyph[i] ? 0 : 255;
|
||||
int diff = patch[i] - expected;
|
||||
err_inverted += diff * diff;
|
||||
}
|
||||
|
||||
if (err_inverted < best_error) {
|
||||
best_error = err_inverted;
|
||||
best_glyph = g;
|
||||
best_bg = COLOR_WHITE;
|
||||
best_fg = COLOR_BLACK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*out_bg = best_bg;
|
||||
*out_fg = best_fg;
|
||||
return best_glyph;
|
||||
}
|
||||
|
||||
// Convert frame to text mode
|
||||
void frame_to_text(const uint8_t *pixels, const FontROM *rom,
|
||||
uint8_t *bg_col, uint8_t *fg_col, uint8_t *chars) {
|
||||
uint8_t patch[PATCH_SZ];
|
||||
|
||||
for (int gr = 0; gr < GRID_H; gr++) {
|
||||
for (int gc = 0; gc < GRID_W; gc++) {
|
||||
int idx = gr * GRID_W + gc;
|
||||
|
||||
// Extract patch
|
||||
for (int y = 0; y < CHAR_H; y++) {
|
||||
for (int x = 0; x < CHAR_W; x++) {
|
||||
int px = gc * CHAR_W + x;
|
||||
int py = gr * CHAR_H + y;
|
||||
patch[y * CHAR_W + x] = pixels[py * PIXEL_W + px];
|
||||
}
|
||||
}
|
||||
|
||||
// Find best match
|
||||
chars[idx] = find_best_glyph(patch, rom, &bg_col[idx], &fg_col[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current time in nanoseconds since UNIX epoch
|
||||
uint64_t get_current_time_ns(void) {
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, NULL);
|
||||
return (uint64_t)tv.tv_sec * 1000000000ULL + (uint64_t)tv.tv_usec * 1000ULL;
|
||||
}
|
||||
|
||||
// Parse MP2 packet header to get accurate packet size
|
||||
int get_mp2_packet_size(uint8_t *header) {
|
||||
int bitrate_index = (header[2] >> 4) & 0x0F;
|
||||
int bitrates[] = {0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384};
|
||||
if (bitrate_index >= 15) return MP2_DEFAULT_PACKET_SIZE;
|
||||
|
||||
int bitrate = bitrates[bitrate_index];
|
||||
if (bitrate == 0) return MP2_DEFAULT_PACKET_SIZE;
|
||||
|
||||
int sampling_freq_index = (header[2] >> 2) & 0x03;
|
||||
int sampling_freqs[] = {44100, 48000, 32000, 0};
|
||||
int sampling_freq = sampling_freqs[sampling_freq_index];
|
||||
if (sampling_freq == 0) return MP2_DEFAULT_PACKET_SIZE;
|
||||
|
||||
int padding = (header[2] >> 1) & 0x01;
|
||||
return (144 * bitrate * 1000) / sampling_freq + padding;
|
||||
}
|
||||
|
||||
// Write Videotex header (32 bytes, similar to TAV but simpler)
|
||||
void write_videotex_header(FILE *f, uint8_t fps, uint32_t total_frames) {
|
||||
fwrite("\x1FTSVMTAV", 8, 1, f);
|
||||
|
||||
// Version: 1 (uint8)
|
||||
fputc(1, f);
|
||||
|
||||
// Grid dimensions (uint8 each)
|
||||
uint16_t width = GRID_W;
|
||||
uint16_t height = GRID_H;
|
||||
fwrite(&width, sizeof(uint16_t), 1, f); // cols = 80
|
||||
fwrite(&height, sizeof(uint16_t), 1, f); // rows = 32
|
||||
|
||||
// FPS (uint8)
|
||||
fputc(fps, f);
|
||||
|
||||
// Total frames (uint32, little-endian)
|
||||
fwrite(&total_frames, sizeof(uint32_t), 1, f);
|
||||
|
||||
fputc(0, f); // wavelet filter type
|
||||
fputc(0, f); // decomposition levels
|
||||
fputc(0, f); // quantiser Y
|
||||
fputc(0, f); // quantiser Co
|
||||
fputc(0, f); // quantiser Cg
|
||||
|
||||
// Feature Flags
|
||||
fputc(0x03, f); // bit 0 = has audio; bit 1 = has subtitle (Videotex is classified as subtitles)
|
||||
|
||||
// Video Flags
|
||||
fputc(0x80, f); // bit 7 = has no video (Videotex is classified as subtitles)
|
||||
|
||||
|
||||
fputc(0, f); // encoder quality level
|
||||
fputc(0x02, f); // channel layout: Y only
|
||||
fputc(0, f); // entropy coder
|
||||
|
||||
fputc(0, f); // reserved
|
||||
fputc(0, f); // reserved
|
||||
|
||||
fputc(0, f); // device orientation: no rotation
|
||||
fputc(0, f); // file role: generic
|
||||
}
|
||||
|
||||
// Write extended header packet with metadata
|
||||
// Returns the file offset where ENDT value is written (for later update)
|
||||
long write_extended_header(FILE *f, uint64_t creation_time_ns, const char *ffmpeg_version) {
|
||||
fputc(PACKET_EXTENDED_HDR, f);
|
||||
|
||||
// Helper macros for key-value pairs
|
||||
#define WRITE_KV_UINT64(key_str, value) do { \
|
||||
fwrite(key_str, 1, 4, f); \
|
||||
uint8_t value_type = 0x04; /* Uint64 */ \
|
||||
fwrite(&value_type, 1, 1, f); \
|
||||
uint64_t val = (value); \
|
||||
fwrite(&val, sizeof(uint64_t), 1, f); \
|
||||
} while(0)
|
||||
|
||||
#define WRITE_KV_BYTES(key_str, data, len) do { \
|
||||
fwrite(key_str, 1, 4, f); \
|
||||
uint8_t value_type = 0x10; /* Bytes */ \
|
||||
fwrite(&value_type, 1, 1, f); \
|
||||
uint16_t length = (len); \
|
||||
fwrite(&length, sizeof(uint16_t), 1, f); \
|
||||
fwrite((data), 1, (len), f); \
|
||||
} while(0)
|
||||
|
||||
// Count key-value pairs (BGNT, ENDT, CDAT, VNDR, FMPG)
|
||||
uint16_t num_pairs = ffmpeg_version ? 5 : 4; // FMPG is optional
|
||||
fwrite(&num_pairs, sizeof(uint16_t), 1, f);
|
||||
|
||||
// BGNT: Video begin time (0 for frame 0)
|
||||
WRITE_KV_UINT64("BGNT", 0ULL);
|
||||
|
||||
// ENDT: Video end time (placeholder, will be updated at end)
|
||||
long endt_offset = ftell(f);
|
||||
WRITE_KV_UINT64("ENDT", 0ULL);
|
||||
|
||||
// CDAT: Creation time in nanoseconds since UNIX epoch
|
||||
WRITE_KV_UINT64("CDAT", creation_time_ns);
|
||||
|
||||
// VNDR: Encoder name and version
|
||||
const char *vendor_str = ENCODER_VENDOR_STRING;
|
||||
WRITE_KV_BYTES("VNDR", vendor_str, strlen(vendor_str));
|
||||
|
||||
// FMPG: FFmpeg version (if available)
|
||||
if (ffmpeg_version) {
|
||||
WRITE_KV_BYTES("FMPG", ffmpeg_version, strlen(ffmpeg_version));
|
||||
}
|
||||
|
||||
#undef WRITE_KV_UINT64
|
||||
#undef WRITE_KV_BYTES
|
||||
|
||||
// Return offset of ENDT value (skip key, type byte)
|
||||
return endt_offset + 4 + 1; // 4 bytes for "ENDT", 1 byte for type
|
||||
}
|
||||
|
||||
// Write font ROM packet (SSF packet type 0x30)
|
||||
void write_fontrom_packet(FILE *f, const uint8_t *rom_data, size_t data_size, uint8_t opcode) {
|
||||
// Prepare padded ROM data (pad to FONTROM_PADDED_SIZE with zeros)
|
||||
uint8_t *padded_data = calloc(1, FONTROM_PADDED_SIZE);
|
||||
memcpy(padded_data, rom_data, data_size);
|
||||
|
||||
// Packet structure:
|
||||
// [type:0x30][size:uint32][index:uint24][opcode:uint8][length:uint16][data][terminator:0x00]
|
||||
uint32_t packet_size = 3 + 1 + 2 + FONTROM_PADDED_SIZE + 1;
|
||||
|
||||
// Write packet type and size
|
||||
fputc(PACKET_SSF, f);
|
||||
fwrite(&packet_size, sizeof(uint32_t), 1, f);
|
||||
|
||||
// Write SSF payload
|
||||
// Index (3 bytes, always 0 for font ROM)
|
||||
fputc(0, f);
|
||||
fputc(0, f);
|
||||
fputc(0, f);
|
||||
|
||||
// Opcode (0x80=lowrom, 0x81=highrom)
|
||||
fputc(opcode, f);
|
||||
|
||||
// Payload length (uint16, little-endian)
|
||||
uint16_t payload_len = FONTROM_PADDED_SIZE;
|
||||
fwrite(&payload_len, sizeof(uint16_t), 1, f);
|
||||
|
||||
// Font data (padded to 1920 bytes)
|
||||
fwrite(padded_data, 1, FONTROM_PADDED_SIZE, f);
|
||||
|
||||
// Terminator
|
||||
fputc(0x00, f);
|
||||
|
||||
free(padded_data);
|
||||
|
||||
fprintf(stderr, "Font ROM uploaded: %zu bytes (padded to %d), opcode 0x%02X\n",
|
||||
data_size, FONTROM_PADDED_SIZE, opcode);
|
||||
}
|
||||
|
||||
// Write timecode packet (nanoseconds)
|
||||
void write_timecode(FILE *f, uint64_t timecode_ns) {
|
||||
fputc(PACKET_TIMECODE, f);
|
||||
fwrite(&timecode_ns, sizeof(uint64_t), 1, f);
|
||||
}
|
||||
|
||||
// Write sync packet
|
||||
void write_sync(FILE *f) {
|
||||
fputc(PACKET_SYNC, f);
|
||||
}
|
||||
|
||||
// Write MP2 audio packet
|
||||
void write_audio_mp2(FILE *f, const uint8_t *data, uint32_t size) {
|
||||
fputc(PACKET_AUDIO_MP2, f);
|
||||
fwrite(&size, sizeof(uint32_t), 1, f);
|
||||
fwrite(data, 1, size, f);
|
||||
}
|
||||
|
||||
// Write text packet with separated arrays (better compression)
|
||||
void write_text_packet(FILE *f, const uint8_t *bg_col, const uint8_t *fg_col,
|
||||
const uint8_t *chars, int rows, int cols) {
|
||||
int grid_size = rows * cols;
|
||||
|
||||
// Prepare uncompressed data: [rows][cols][fg-array][bg-array][char-array]
|
||||
// Separated arrays compress much better (fg/bg are just 0xF0/0xFE runs)
|
||||
size_t uncompressed_size = 2 + grid_size * 3;
|
||||
uint8_t *uncompressed = malloc(uncompressed_size);
|
||||
|
||||
uncompressed[0] = rows;
|
||||
uncompressed[1] = cols;
|
||||
|
||||
// Copy arrays in order: foreground, background, characters
|
||||
memcpy(&uncompressed[2], fg_col, grid_size); // Foreground first
|
||||
memcpy(&uncompressed[2 + grid_size], bg_col, grid_size); // Background second
|
||||
memcpy(&uncompressed[2 + grid_size * 2], chars, grid_size); // Characters third
|
||||
|
||||
// Compress with Zstd
|
||||
size_t max_compressed = ZSTD_compressBound(uncompressed_size);
|
||||
uint8_t *compressed = malloc(max_compressed);
|
||||
size_t compressed_size = ZSTD_compress(compressed, max_compressed,
|
||||
uncompressed, uncompressed_size, 3);
|
||||
|
||||
if (ZSTD_isError(compressed_size)) {
|
||||
fprintf(stderr, "Zstd compression error\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Write packet: [type][size][data]
|
||||
fputc(PACKET_TEXT, f);
|
||||
uint32_t size32 = compressed_size;
|
||||
fwrite(&size32, 4, 1, f);
|
||||
fwrite(compressed, compressed_size, 1, f);
|
||||
|
||||
free(compressed);
|
||||
free(uncompressed);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 7) {
|
||||
fprintf(stderr, "Usage: %s -i <video> -f <font.chr> -o <output.tav> [--no-invert-char]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *input_video = NULL;
|
||||
const char *font_path = NULL;
|
||||
const char *output_path = NULL;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "-i") == 0 && i+1 < argc) input_video = argv[++i];
|
||||
else if (strcmp(argv[i], "-f") == 0 && i+1 < argc) font_path = argv[++i];
|
||||
else if (strcmp(argv[i], "-o") == 0 && i+1 < argc) output_path = argv[++i];
|
||||
else if (strcmp(argv[i], "--no-invert-char") == 0) g_no_invert_char = 1;
|
||||
}
|
||||
|
||||
if (!input_video || !font_path || !output_path) {
|
||||
fprintf(stderr, "Missing required arguments\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (g_no_invert_char) {
|
||||
fprintf(stderr, "Inverted character matching disabled\n");
|
||||
}
|
||||
|
||||
// Generate random temp filename for audio
|
||||
generate_random_filename(TEMP_AUDIO_FILE);
|
||||
|
||||
// Capture creation time and FFmpeg version for extended header
|
||||
uint64_t creation_time_ns = get_current_time_ns();
|
||||
char *ffmpeg_version = get_ffmpeg_version();
|
||||
|
||||
// Detect video FPS
|
||||
float fps_float = detect_fps(input_video);
|
||||
uint8_t fps = (uint8_t)(fps_float + 0.5f); // Round to nearest integer
|
||||
fprintf(stderr, "Detected FPS: %.2f (using %d in TAV header)\n", fps_float, fps);
|
||||
|
||||
// Load font ROM
|
||||
FontROM *rom = load_font_rom(font_path);
|
||||
if (!rom) {
|
||||
fprintf(stderr, "Failed to load font ROM: %s\n", font_path);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Open FFmpeg pipe for grayscale frames at 560×448
|
||||
char ffmpeg_cmd[1024];
|
||||
snprintf(ffmpeg_cmd, sizeof(ffmpeg_cmd),
|
||||
"ffmpeg -i \"%s\" -vf \"scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d\" "
|
||||
"-f rawvideo -pix_fmt gray - 2>/dev/null",
|
||||
input_video, PIXEL_W, PIXEL_H, PIXEL_W, PIXEL_H);
|
||||
|
||||
fprintf(stderr, "Opening video stream...\n");
|
||||
FILE *video_pipe = popen(ffmpeg_cmd, "r");
|
||||
if (!video_pipe) {
|
||||
fprintf(stderr, "Failed to open FFmpeg pipe\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Extract MP2 audio to temporary file using libtwolame
|
||||
fprintf(stderr, "Extracting MP2 audio...\n");
|
||||
char audio_cmd[1024];
|
||||
snprintf(audio_cmd, sizeof(audio_cmd),
|
||||
"ffmpeg -v quiet -i \"%s\" -acodec libtwolame -psymodel 4 -b:a 224k -ar %d -ac 2 -y \"%s\" 2>/dev/null",
|
||||
input_video, SAMPLE_RATE, TEMP_AUDIO_FILE);
|
||||
|
||||
int audio_result = system(audio_cmd);
|
||||
if (audio_result != 0) {
|
||||
fprintf(stderr, "Warning: Audio extraction failed, continuing without audio\n");
|
||||
}
|
||||
|
||||
// Open MP2 file for reading
|
||||
FILE *mp2_file = NULL;
|
||||
long audio_remaining = 0;
|
||||
if (audio_result == 0) {
|
||||
mp2_file = fopen(TEMP_AUDIO_FILE, "rb");
|
||||
if (mp2_file) {
|
||||
fseek(mp2_file, 0, SEEK_END);
|
||||
audio_remaining = ftell(mp2_file);
|
||||
fseek(mp2_file, 0, SEEK_SET);
|
||||
fprintf(stderr, "Audio ready: %ld bytes\n", audio_remaining);
|
||||
}
|
||||
}
|
||||
|
||||
// Open output file
|
||||
FILE *out = fopen(output_path, "wb");
|
||||
if (!out) {
|
||||
fprintf(stderr, "Failed to open output file\n");
|
||||
pclose(video_pipe);
|
||||
if (mp2_file) fclose(mp2_file);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write Videotex header with placeholder total_frames (will update at end)
|
||||
long header_offset = ftell(out);
|
||||
write_videotex_header(out, fps, 0);
|
||||
|
||||
// Write extended header packet (before first timecode)
|
||||
long endt_offset = write_extended_header(out, creation_time_ns, ffmpeg_version);
|
||||
|
||||
// Upload font ROM to TSVM (split into lowrom and highrom)
|
||||
fprintf(stderr, "Uploading font ROM to TSVM...\n");
|
||||
FILE *rom_file = fopen(font_path, "rb");
|
||||
if (rom_file) {
|
||||
fseek(rom_file, 0, SEEK_END);
|
||||
long rom_size = ftell(rom_file);
|
||||
fseek(rom_file, 0, SEEK_SET);
|
||||
|
||||
uint8_t *raw_rom = malloc(rom_size);
|
||||
if (raw_rom && fread(raw_rom, 1, rom_size, rom_file) == rom_size) {
|
||||
// Split into lowrom and highrom
|
||||
size_t bytes_per_half = (GLYPHS_PER_ROM * 14); // 128 glyphs × 14 bytes = 1792
|
||||
|
||||
// Write lowrom (first 128 glyphs)
|
||||
if (rom_size >= bytes_per_half) {
|
||||
write_fontrom_packet(out, raw_rom, bytes_per_half, SSF_OPCODE_LOWROM);
|
||||
}
|
||||
|
||||
// Write highrom (second 128 glyphs)
|
||||
if (rom_size >= bytes_per_half * 2) {
|
||||
write_fontrom_packet(out, raw_rom + bytes_per_half, bytes_per_half, SSF_OPCODE_HIGHROM);
|
||||
} else if (rom_size > bytes_per_half) {
|
||||
// Partial highrom
|
||||
write_fontrom_packet(out, raw_rom + bytes_per_half, rom_size - bytes_per_half, SSF_OPCODE_HIGHROM);
|
||||
}
|
||||
|
||||
free(raw_rom);
|
||||
}
|
||||
fclose(rom_file);
|
||||
}
|
||||
|
||||
// Allocate buffers
|
||||
size_t frame_size = PIXEL_W * PIXEL_H;
|
||||
uint8_t *gray_pixels = malloc(frame_size);
|
||||
uint8_t *bg_col = malloc(GRID_W * GRID_H);
|
||||
uint8_t *fg_col = malloc(GRID_W * GRID_H);
|
||||
uint8_t *chars = malloc(GRID_W * GRID_H);
|
||||
|
||||
// Audio buffer for MP2 packets
|
||||
#define MP2_BUFFER_SIZE 2048
|
||||
uint8_t *audio_buffer = malloc(MP2_BUFFER_SIZE);
|
||||
|
||||
uint32_t frame_num = 0;
|
||||
uint64_t total_audio_bytes = 0;
|
||||
|
||||
// Audio timing calculation
|
||||
double frame_audio_time = 1.0 / fps_float; // Time per video frame
|
||||
double packet_audio_time = (double)MP2_DEFAULT_PACKET_SIZE / SAMPLE_RATE; // Time per audio packet
|
||||
double packets_per_frame = frame_audio_time / packet_audio_time;
|
||||
double audio_frames_in_buffer = 0.0; // Simulated audio buffer level
|
||||
|
||||
fprintf(stderr, "Encoding text-mode video (%dx%d chars, %dx%d pixels)...\n",
|
||||
GRID_W, GRID_H, PIXEL_W, PIXEL_H);
|
||||
|
||||
// Track encoding start time
|
||||
struct timeval start_time, now;
|
||||
gettimeofday(&start_time, NULL);
|
||||
|
||||
// Read and process frames
|
||||
while (fread(gray_pixels, 1, frame_size, video_pipe) == frame_size) {
|
||||
// Calculate timecode in nanoseconds
|
||||
uint64_t timecode_ns = (uint64_t)(frame_num * 1000000000.0 / fps_float);
|
||||
|
||||
// Write audio packets for this frame (based on timing)
|
||||
if (mp2_file && audio_remaining > 0) {
|
||||
// Simulate buffer consumption
|
||||
audio_frames_in_buffer -= packets_per_frame;
|
||||
|
||||
// Calculate how many packets we need to maintain buffer
|
||||
double target_level = fmax(packets_per_frame, 2.0);
|
||||
int packets_to_insert = 0;
|
||||
|
||||
if (audio_frames_in_buffer < target_level) {
|
||||
double deficit = target_level - audio_frames_in_buffer;
|
||||
packets_to_insert = (int)ceil(deficit);
|
||||
}
|
||||
|
||||
// Insert the calculated number of audio packets
|
||||
for (int q = 0; q < packets_to_insert; q++) {
|
||||
// Peek at header to get actual packet size
|
||||
long pos = ftell(mp2_file);
|
||||
uint8_t header[4];
|
||||
if (fread(header, 1, 4, mp2_file) != 4) break;
|
||||
fseek(mp2_file, pos, SEEK_SET); // Rewind to re-read with full packet
|
||||
|
||||
int actual_packet_size = get_mp2_packet_size(header);
|
||||
size_t bytes_to_read = actual_packet_size;
|
||||
|
||||
// Clamp to remaining audio
|
||||
if (bytes_to_read > audio_remaining) {
|
||||
bytes_to_read = audio_remaining;
|
||||
}
|
||||
|
||||
// Sanity check
|
||||
if (bytes_to_read > MP2_BUFFER_SIZE) {
|
||||
fprintf(stderr, "ERROR: MP2 packet size %zu exceeds buffer\n", bytes_to_read);
|
||||
break;
|
||||
}
|
||||
|
||||
// Read full packet
|
||||
size_t bytes_read = fread(audio_buffer, 1, bytes_to_read, mp2_file);
|
||||
if (bytes_read == 0) break;
|
||||
|
||||
// Write MP2 audio packet
|
||||
write_audio_mp2(out, audio_buffer, bytes_read);
|
||||
|
||||
// Track audio
|
||||
audio_remaining -= bytes_read;
|
||||
audio_frames_in_buffer++;
|
||||
total_audio_bytes += bytes_read;
|
||||
}
|
||||
}
|
||||
|
||||
// Write timecode
|
||||
write_timecode(out, timecode_ns);
|
||||
|
||||
// Convert to text mode
|
||||
frame_to_text(gray_pixels, rom, bg_col, fg_col, chars);
|
||||
|
||||
// Write text packet (treated as I-frame)
|
||||
write_text_packet(out, bg_col, fg_col, chars, GRID_H, GRID_W);
|
||||
|
||||
// Write sync packet after each frame
|
||||
write_sync(out);
|
||||
|
||||
frame_num++;
|
||||
if (frame_num % 30 == 0) {
|
||||
// Calculate encoding speed
|
||||
gettimeofday(&now, NULL);
|
||||
double elapsed = (now.tv_sec - start_time.tv_sec) +
|
||||
(now.tv_usec - start_time.tv_usec) / 1000000.0;
|
||||
double encoding_fps = frame_num / elapsed;
|
||||
|
||||
fprintf(stderr, "\rEncoded %u frames (%.1f fps)", frame_num, encoding_fps);
|
||||
fflush(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Write any remaining audio
|
||||
if (mp2_file && audio_remaining > 0) {
|
||||
while (audio_remaining > 0) {
|
||||
// Peek at header to get actual packet size
|
||||
long pos = ftell(mp2_file);
|
||||
uint8_t header[4];
|
||||
if (fread(header, 1, 4, mp2_file) != 4) break;
|
||||
fseek(mp2_file, pos, SEEK_SET);
|
||||
|
||||
int actual_packet_size = get_mp2_packet_size(header);
|
||||
size_t bytes_to_read = (actual_packet_size < audio_remaining) ? actual_packet_size : audio_remaining;
|
||||
|
||||
if (bytes_to_read > MP2_BUFFER_SIZE) break;
|
||||
|
||||
size_t bytes_read = fread(audio_buffer, 1, bytes_to_read, mp2_file);
|
||||
if (bytes_read == 0) break;
|
||||
|
||||
write_audio_mp2(out, audio_buffer, bytes_read);
|
||||
audio_remaining -= bytes_read;
|
||||
total_audio_bytes += bytes_read;
|
||||
}
|
||||
}
|
||||
|
||||
// Final timing
|
||||
gettimeofday(&now, NULL);
|
||||
double total_time = (now.tv_sec - start_time.tv_sec) +
|
||||
(now.tv_usec - start_time.tv_usec) / 1000000.0;
|
||||
double final_fps = frame_num / total_time;
|
||||
|
||||
fprintf(stderr, "\nDone! Encoded %u frames in %.2fs (%.1f fps)\n",
|
||||
frame_num, total_time, final_fps);
|
||||
fprintf(stderr, "Audio: %llu bytes (%.2f MB)\n",
|
||||
(unsigned long long)total_audio_bytes,
|
||||
total_audio_bytes / 1024.0 / 1024.0);
|
||||
|
||||
// Update total_frames in header
|
||||
if (frame_num > 0) {
|
||||
fseek(out, header_offset + 14, SEEK_SET); // Offset to total_frames field
|
||||
fwrite(&frame_num, sizeof(uint32_t), 1, out);
|
||||
fprintf(stderr, "Updated total_frames in header: %u\n", frame_num);
|
||||
}
|
||||
|
||||
// Update ENDT in extended header (calculate end time for last frame)
|
||||
if (frame_num > 0) {
|
||||
// Calculate duration: (frame_num - 1) frames * (1/fps) seconds in nanoseconds
|
||||
uint64_t duration_ns = (uint64_t)((frame_num - 1) * 1000000000.0 / fps_float);
|
||||
uint64_t endt_ns = duration_ns;
|
||||
|
||||
fseek(out, endt_offset, SEEK_SET);
|
||||
fwrite(&endt_ns, sizeof(uint64_t), 1, out);
|
||||
fprintf(stderr, "Updated ENDT in extended header: %llu ns (%.3f seconds)\n",
|
||||
(unsigned long long)endt_ns, endt_ns / 1000000000.0);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
pclose(video_pipe);
|
||||
if (mp2_file) {
|
||||
fclose(mp2_file);
|
||||
unlink(TEMP_AUDIO_FILE); // Remove temporary audio file
|
||||
}
|
||||
fclose(out);
|
||||
free(gray_pixels);
|
||||
free(bg_col);
|
||||
free(fg_col);
|
||||
free(chars);
|
||||
free(audio_buffer);
|
||||
free(rom->data);
|
||||
free(rom);
|
||||
if (ffmpeg_version) free(ffmpeg_version);
|
||||
|
||||
return 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,169 +0,0 @@
|
||||
// Affine estimation for TAV mesh warping
|
||||
// This file contains logic to estimate per-cell affine transforms from block motion
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
extern "C" {
|
||||
|
||||
// Estimate affine transform for a mesh cell from surrounding block motion vectors
|
||||
// Uses least-squares fitting of motion vectors to affine model: [x'] = [a11 a12][x] + [tx]
|
||||
// [y'] [a21 a22][y] [ty]
|
||||
//
|
||||
// Returns 1 if affine improves residual by >threshold, 0 if translation-only is better
|
||||
int estimate_cell_affine(
|
||||
const float *flow_x, const float *flow_y,
|
||||
int width, int height,
|
||||
int cell_x, int cell_y, // Cell position in mesh coordinates
|
||||
int cell_w, int cell_h, // Cell size in pixels
|
||||
float threshold, // Residual improvement threshold (e.g. 0.10 = 10%)
|
||||
short *out_tx, short *out_ty, // Translation (1/8 pixel)
|
||||
short *out_a11, short *out_a12, // Affine matrix (1/256 fixed-point)
|
||||
short *out_a21, short *out_a22
|
||||
) {
|
||||
// Compute cell bounding box
|
||||
int x_start = cell_x * cell_w;
|
||||
int y_start = cell_y * cell_h;
|
||||
int x_end = (cell_x + 1) * cell_w;
|
||||
int y_end = (cell_y + 1) * cell_h;
|
||||
if (x_end > width) x_end = width;
|
||||
if (y_end > height) y_end = height;
|
||||
|
||||
// Sample motion vectors from a 4×4 grid within the cell
|
||||
const int samples_x = 4;
|
||||
const int samples_y = 4;
|
||||
float sample_motion_x[16];
|
||||
float sample_motion_y[16];
|
||||
int sample_px[16];
|
||||
int sample_py[16];
|
||||
int n_samples = 0;
|
||||
|
||||
for (int sy = 0; sy < samples_y; sy++) {
|
||||
for (int sx = 0; sx < samples_x; sx++) {
|
||||
int px = x_start + (x_end - x_start) * sx / (samples_x - 1);
|
||||
int py = y_start + (y_end - y_start) * sy / (samples_y - 1);
|
||||
|
||||
if (px >= width) px = width - 1;
|
||||
if (py >= height) py = height - 1;
|
||||
|
||||
int idx = py * width + px;
|
||||
sample_motion_x[n_samples] = flow_x[idx];
|
||||
sample_motion_y[n_samples] = flow_y[idx];
|
||||
sample_px[n_samples] = px - (x_start + x_end) / 2; // Relative to cell center
|
||||
sample_py[n_samples] = py - (y_start + y_end) / 2;
|
||||
n_samples++;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Compute translation-only model (average motion)
|
||||
float avg_dx = 0, avg_dy = 0;
|
||||
for (int i = 0; i < n_samples; i++) {
|
||||
avg_dx += sample_motion_x[i];
|
||||
avg_dy += sample_motion_y[i];
|
||||
}
|
||||
avg_dx /= n_samples;
|
||||
avg_dy /= n_samples;
|
||||
|
||||
// Translation residual
|
||||
float trans_residual = 0;
|
||||
for (int i = 0; i < n_samples; i++) {
|
||||
float dx_err = sample_motion_x[i] - avg_dx;
|
||||
float dy_err = sample_motion_y[i] - avg_dy;
|
||||
trans_residual += dx_err * dx_err + dy_err * dy_err;
|
||||
}
|
||||
|
||||
// 2. Estimate affine model using least-squares
|
||||
// Solve: [vx] = [a11 a12][px] + [tx]
|
||||
// [vy] [a21 a22][py] [ty]
|
||||
// Using normal equations for 2×2 affine
|
||||
|
||||
double sum_x = 0, sum_y = 0, sum_xx = 0, sum_yy = 0, sum_xy = 0;
|
||||
double sum_vx = 0, sum_vy = 0, sum_vx_x = 0, sum_vx_y = 0;
|
||||
double sum_vy_x = 0, sum_vy_y = 0;
|
||||
|
||||
for (int i = 0; i < n_samples; i++) {
|
||||
double px = sample_px[i];
|
||||
double py = sample_py[i];
|
||||
double vx = sample_motion_x[i];
|
||||
double vy = sample_motion_y[i];
|
||||
|
||||
sum_x += px;
|
||||
sum_y += py;
|
||||
sum_xx += px * px;
|
||||
sum_yy += py * py;
|
||||
sum_xy += px * py;
|
||||
sum_vx += vx;
|
||||
sum_vy += vy;
|
||||
sum_vx_x += vx * px;
|
||||
sum_vx_y += vx * py;
|
||||
sum_vy_x += vy * px;
|
||||
sum_vy_y += vy * py;
|
||||
}
|
||||
|
||||
// Solve 2×2 system for [a11, a12, tx] and [a21, a22, ty]
|
||||
double n = n_samples;
|
||||
double det = n * sum_xx * sum_yy + 2 * sum_x * sum_y * sum_xy -
|
||||
sum_xx * sum_y * sum_y - sum_yy * sum_x * sum_x - n * sum_xy * sum_xy;
|
||||
|
||||
if (fabs(det) < 1e-6) {
|
||||
// Singular matrix, fall back to translation
|
||||
*out_tx = (short)(avg_dx * 8.0f);
|
||||
*out_ty = (short)(avg_dy * 8.0f);
|
||||
*out_a11 = 256; // Identity
|
||||
*out_a12 = 0;
|
||||
*out_a21 = 0;
|
||||
*out_a22 = 256;
|
||||
return 0; // Translation only
|
||||
}
|
||||
|
||||
// Solve for affine parameters (simplified for readability)
|
||||
double a11 = (sum_vx_x * sum_yy * n - sum_vx_y * sum_xy * n - sum_vx * sum_y * sum_y +
|
||||
sum_vx * sum_xy * sum_y + sum_vx_y * sum_x * sum_y - sum_vx_x * sum_y * sum_y) / det;
|
||||
double a12 = (sum_vx_y * sum_xx * n - sum_vx_x * sum_xy * n - sum_vx * sum_x * sum_xy +
|
||||
sum_vx * sum_xx * sum_y + sum_vx_x * sum_x * sum_y - sum_vx_y * sum_x * sum_x) / det;
|
||||
double tx = (sum_vx - a11 * sum_x - a12 * sum_y) / n;
|
||||
|
||||
double a21 = (sum_vy_x * sum_yy * n - sum_vy_y * sum_xy * n - sum_vy * sum_y * sum_y +
|
||||
sum_vy * sum_xy * sum_y + sum_vy_y * sum_x * sum_y - sum_vy_x * sum_y * sum_y) / det;
|
||||
double a22 = (sum_vy_y * sum_xx * n - sum_vy_x * sum_xy * n - sum_vy * sum_x * sum_xy +
|
||||
sum_vy * sum_xx * sum_y + sum_vy_x * sum_x * sum_y - sum_vy_y * sum_x * sum_x) / det;
|
||||
double ty = (sum_vy - a21 * sum_x - a22 * sum_y) / n;
|
||||
|
||||
// Affine residual
|
||||
float affine_residual = 0;
|
||||
for (int i = 0; i < n_samples; i++) {
|
||||
double px = sample_px[i];
|
||||
double py = sample_py[i];
|
||||
double pred_vx = a11 * px + a12 * py + tx;
|
||||
double pred_vy = a21 * px + a22 * py + ty;
|
||||
double dx_err = sample_motion_x[i] - pred_vx;
|
||||
double dy_err = sample_motion_y[i] - pred_vy;
|
||||
affine_residual += dx_err * dx_err + dy_err * dy_err;
|
||||
}
|
||||
|
||||
// Decision: Use affine if residual improves by > threshold
|
||||
float improvement = (trans_residual - affine_residual) / (trans_residual + 1e-6f);
|
||||
|
||||
if (improvement > threshold) {
|
||||
// Use affine
|
||||
*out_tx = (short)(tx * 8.0f);
|
||||
*out_ty = (short)(ty * 8.0f);
|
||||
*out_a11 = (short)(a11 * 256.0);
|
||||
*out_a12 = (short)(a12 * 256.0);
|
||||
*out_a21 = (short)(a21 * 256.0);
|
||||
*out_a22 = (short)(a22 * 256.0);
|
||||
return 1; // Affine
|
||||
} else {
|
||||
// Use translation
|
||||
*out_tx = (short)(avg_dx * 8.0f);
|
||||
*out_ty = (short)(avg_dy * 8.0f);
|
||||
*out_a11 = 256; // Identity
|
||||
*out_a12 = 0;
|
||||
*out_a21 = 0;
|
||||
*out_a22 = 256;
|
||||
return 0; // Translation only
|
||||
}
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
Binary file not shown.
@@ -1,65 +0,0 @@
|
||||
// Simple coefficient preprocessing for better compression
|
||||
// Insert right before Zstd compression
|
||||
|
||||
#ifndef COEFFICIENT_COMPRESS_H
|
||||
#define COEFFICIENT_COMPRESS_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
// Preprocess coefficients using significance map
|
||||
// Returns new buffer size, modifies buffer in-place if possible
|
||||
static size_t preprocess_coefficients(int16_t *coeffs, int coeff_count, uint8_t *output_buffer) {
|
||||
// Count non-zero coefficients
|
||||
int nonzero_count = 0;
|
||||
for (int i = 0; i < coeff_count; i++) {
|
||||
if (coeffs[i] != 0) nonzero_count++;
|
||||
}
|
||||
|
||||
// Create significance map (1 bit per coefficient, packed into bytes)
|
||||
int map_bytes = (coeff_count + 7) / 8; // Round up to nearest byte
|
||||
uint8_t *sig_map = output_buffer;
|
||||
int16_t *values = (int16_t *)(output_buffer + map_bytes);
|
||||
|
||||
// Clear significance map
|
||||
memset(sig_map, 0, map_bytes);
|
||||
|
||||
// Fill significance map and extract non-zero values
|
||||
int value_idx = 0;
|
||||
for (int i = 0; i < coeff_count; i++) {
|
||||
if (coeffs[i] != 0) {
|
||||
// Set bit in significance map
|
||||
int byte_idx = i / 8;
|
||||
int bit_idx = i % 8;
|
||||
sig_map[byte_idx] |= (1 << bit_idx);
|
||||
|
||||
// Store the value
|
||||
values[value_idx++] = coeffs[i];
|
||||
}
|
||||
}
|
||||
|
||||
return map_bytes + (nonzero_count * sizeof(int16_t));
|
||||
}
|
||||
|
||||
// Decoder: reconstruct coefficients from significance map
|
||||
static void postprocess_coefficients(uint8_t *compressed_data, int coeff_count, int16_t *output_coeffs) {
|
||||
int map_bytes = (coeff_count + 7) / 8;
|
||||
uint8_t *sig_map = compressed_data;
|
||||
int16_t *values = (int16_t *)(compressed_data + map_bytes);
|
||||
|
||||
// Clear output
|
||||
memset(output_coeffs, 0, coeff_count * sizeof(int16_t));
|
||||
|
||||
// Reconstruct coefficients
|
||||
int value_idx = 0;
|
||||
for (int i = 0; i < coeff_count; i++) {
|
||||
int byte_idx = i / 8;
|
||||
int bit_idx = i % 8;
|
||||
|
||||
if (sig_map[byte_idx] & (1 << bit_idx)) {
|
||||
output_coeffs[i] = values[value_idx++];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif // COEFFICIENT_COMPRESS_H
|
||||
@@ -1,39 +0,0 @@
|
||||
#ifndef TAD32_DECODER_H
|
||||
#define TAD32_DECODER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// TAD32 (Terrarum Advanced Audio - PCM32f version) Decoder
|
||||
// DWT-based perceptual audio codec for TSVM
|
||||
// Shared decoder library used by both decoder_tad (standalone) and decoder_tav (video decoder)
|
||||
|
||||
// Constants (must match encoder)
|
||||
#define TAD32_SAMPLE_RATE 32000
|
||||
#define TAD32_CHANNELS 2 // Stereo
|
||||
#define TAD_DEFAULT_CHUNK_SIZE 32768 // Default chunk size for standalone TAD files
|
||||
|
||||
/**
|
||||
* Decode audio chunk with TAD32 codec
|
||||
*
|
||||
* @param input Input TAD32 chunk data
|
||||
* @param input_size Size of input buffer
|
||||
* @param pcmu8_stereo Output PCMu8 stereo samples (interleaved L,R)
|
||||
* @param bytes_consumed [out] Number of bytes consumed from input
|
||||
* @param samples_decoded [out] Number of samples decoded per channel
|
||||
* @return 0 on success, -1 on error
|
||||
*
|
||||
* Input format:
|
||||
* uint16 sample_count (samples per channel)
|
||||
* uint8 max_index (maximum quantisation index)
|
||||
* uint32 payload_size (bytes in payload)
|
||||
* * payload (encoded M/S data, Zstd-compressed with EZBC)
|
||||
*
|
||||
* Output format:
|
||||
* PCMu8 stereo interleaved (8-bit unsigned PCM, L,R pairs)
|
||||
* Range: [0, 255] where 128 = silence
|
||||
*/
|
||||
int tad32_decode_chunk(const uint8_t *input, size_t input_size, uint8_t *pcmu8_stereo,
|
||||
size_t *bytes_consumed, size_t *samples_decoded);
|
||||
|
||||
#endif // TAD32_DECODER_H
|
||||
@@ -1,63 +0,0 @@
|
||||
#ifndef TAD32_ENCODER_H
|
||||
#define TAD32_ENCODER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// TAD32 (Terrarum Advanced Audio - PCM32f version) Encoder
|
||||
// DWT-based perceptual audio codec for TSVM
|
||||
// Alternative version: PCM32f throughout encoding, PCM8 conversion only at decoder
|
||||
|
||||
// Constants
|
||||
#define TAD32_COEFF_SCALARS {64.0f, 45.255f, 32.0f, 22.627f, 16.0f, 11.314f, 8.0f, 5.657f, 4.0f, 2.828f} // value only valid for CDF 9/7 with decomposition level 9. Index 0 = LL band
|
||||
#define TAD32_MIN_CHUNK_SIZE 1024 // Minimum: 1024 samples
|
||||
#define TAD32_SAMPLE_RATE 32000
|
||||
#define TAD32_CHANNELS 2 // Stereo
|
||||
#define TAD32_QUALITY_MIN 0
|
||||
#define TAD32_QUALITY_MAX 6
|
||||
#define TAD32_QUALITY_DEFAULT 3
|
||||
#define TAD32_ZSTD_LEVEL 15
|
||||
|
||||
static inline int tad32_quality_to_max_index(int quality) {
|
||||
static const int quality_map[6] = {21, 31, 44, 63, 89, 127};
|
||||
if (quality < 0) quality = 0;
|
||||
if (quality > 5) quality = 5;
|
||||
return quality_map[quality];
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode audio chunk with TAD32 codec (PCM32f version)
|
||||
*
|
||||
* @param pcm32_stereo Input PCM32fLE stereo samples (interleaved L,R)
|
||||
* @param num_samples Number of samples per channel (min 1024)
|
||||
* @param max_index Maximum quantisation index (7=3bit, 15=4bit, 31=5bit, 63=6bit, 127=7bit)
|
||||
* @param quantiser_scale Quantiser scaling factor (1.0=baseline, 2.0=2x coarser quantisation)
|
||||
* Higher values = more aggressive quantisation = smaller files
|
||||
* @param zstd_level Zstd compression level (1-22). Use negative value to disable compression.
|
||||
* When disabled, MSB of payload_size is set to indicate uncompressed data.
|
||||
* @param output Output buffer (must be large enough)
|
||||
* @return Number of bytes written to output, or 0 on error
|
||||
*
|
||||
* Output format:
|
||||
* uint16 sample_count (samples per channel)
|
||||
* uint8 max_index (maximum quantisation index)
|
||||
* uint32 payload_size (bytes in payload; MSB=1 indicates uncompressed)
|
||||
* * payload (encoded M/S data, optionally Zstd-compressed)
|
||||
*/
|
||||
size_t tad32_encode_chunk(const float *pcm32_stereo, size_t num_samples,
|
||||
int max_index,
|
||||
float quantiser_scale, int zstd_level, uint8_t *output);
|
||||
|
||||
/**
|
||||
* Print accumulated coefficient statistics
|
||||
* Only effective if TAD_COEFF_STATS environment variable is set
|
||||
*/
|
||||
void tad32_print_statistics(void);
|
||||
|
||||
/**
|
||||
* Free accumulated statistics memory
|
||||
* Should be called after tad32_print_statistics()
|
||||
*/
|
||||
void tad32_free_statistics(void);
|
||||
|
||||
#endif // TAD32_ENCODER_H
|
||||
@@ -1,74 +0,0 @@
|
||||
// TEV Entropy Coder - Specialised for DCT coefficients
|
||||
// Replaces gzip with video-optimized compression
|
||||
#ifndef ENTROPY_CODER_H
|
||||
#define ENTROPY_CODER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
// Bit writer for variable-length codes
|
||||
typedef struct {
|
||||
uint8_t *buffer;
|
||||
size_t buffer_size;
|
||||
size_t byte_pos;
|
||||
int bit_pos; // 0-7, next bit to write
|
||||
} bit_writer_t;
|
||||
|
||||
// Bit reader for decoding
|
||||
typedef struct {
|
||||
const uint8_t *buffer;
|
||||
size_t buffer_size;
|
||||
size_t byte_pos;
|
||||
int bit_pos; // 0-7, next bit to read
|
||||
} bit_reader_t;
|
||||
|
||||
// Huffman table entry
|
||||
typedef struct {
|
||||
uint16_t code; // Huffman code
|
||||
uint8_t bits; // Code length in bits
|
||||
} huffman_entry_t;
|
||||
|
||||
// Video entropy coder optimized for TEV coefficients
|
||||
typedef struct {
|
||||
// Huffman tables for different coefficient types
|
||||
huffman_entry_t y_dc_table[512]; // Y DC coefficients (-255 to +255)
|
||||
huffman_entry_t y_ac_table[512]; // Y AC coefficients
|
||||
huffman_entry_t c_dc_table[512]; // Chroma DC coefficients
|
||||
huffman_entry_t c_ac_table[512]; // Chroma AC coefficients
|
||||
huffman_entry_t run_table[256]; // Zero run lengths (0-255)
|
||||
|
||||
// Motion vector Huffman tables
|
||||
huffman_entry_t mv_table[65]; // Motion vectors (-32 to +32)
|
||||
|
||||
// Bit writer/reader
|
||||
bit_writer_t writer;
|
||||
bit_reader_t reader;
|
||||
} entropy_coder_t;
|
||||
|
||||
static const huffman_entry_t BLOCK_MODE_HUFFMAN[16];
|
||||
|
||||
void write_bits(bit_writer_t *writer, uint32_t value, int bits);
|
||||
uint32_t read_bits(bit_reader_t *reader, int bits);
|
||||
|
||||
// Initialise entropy coder
|
||||
entropy_coder_t* entropy_coder_create(uint8_t *buffer, size_t buffer_size);
|
||||
void entropy_coder_destroy(entropy_coder_t *coder);
|
||||
|
||||
// Encoding functions
|
||||
int encode_y_block(entropy_coder_t *coder, int16_t *y_coeffs);
|
||||
int encode_chroma_block(entropy_coder_t *coder, int16_t *chroma_coeffs, int is_cg);
|
||||
int encode_motion_vector(entropy_coder_t *coder, int16_t mv_x, int16_t mv_y);
|
||||
int encode_block_mode(entropy_coder_t *coder, uint8_t mode);
|
||||
|
||||
// Decoding functions
|
||||
void entropy_coder_init_reader(entropy_coder_t *coder, const uint8_t *buffer, size_t buffer_size);
|
||||
int decode_y_block(entropy_coder_t *coder, int16_t *y_coeffs);
|
||||
int decode_chroma_block(entropy_coder_t *coder, int16_t *chroma_coeffs, int is_cg);
|
||||
int decode_motion_vector(entropy_coder_t *coder, int16_t *mv_x, int16_t *mv_y);
|
||||
int decode_block_mode(entropy_coder_t *coder, uint8_t *mode);
|
||||
|
||||
// Get compressed size
|
||||
size_t entropy_coder_get_size(entropy_coder_t *coder);
|
||||
void entropy_coder_reset(entropy_coder_t *coder);
|
||||
|
||||
#endif // ENTROPY_CODER_H
|
||||
@@ -1,837 +0,0 @@
|
||||
/*
|
||||
* TAV AVX-512 Optimisations
|
||||
*
|
||||
* This file contains AVX-512 optimised versions of performance-critical functions
|
||||
* in the TAV encoder. Runtime CPU detection ensures fallback to scalar versions
|
||||
* on non-AVX-512 systems.
|
||||
*
|
||||
* Optimised functions:
|
||||
* - 1D DWT transforms (5/3, 9/7, Haar, Bior13/7, DD4)
|
||||
* - Quantisation functions
|
||||
* - RGB to YCoCg colour conversion
|
||||
* - 2D DWT gather/scatter operations
|
||||
*
|
||||
* Compile with: -mavx512f -mavx512dq -mavx512bw -mavx512vl
|
||||
*/
|
||||
|
||||
#ifndef TAV_AVX512_H
|
||||
#define TAV_AVX512_H
|
||||
|
||||
#include <immintrin.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
|
||||
// =============================================================================
|
||||
// SIMD Capability Detection
|
||||
// =============================================================================
|
||||
|
||||
typedef enum {
|
||||
SIMD_NONE = 0,
|
||||
SIMD_AVX512F = 1
|
||||
} simd_level_t;
|
||||
|
||||
// Global SIMD level (set by tav_simd_init)
|
||||
static simd_level_t g_simd_level = SIMD_NONE;
|
||||
|
||||
// CPU feature detection
|
||||
static inline int cpu_has_avx512f(void) {
|
||||
#ifdef __AVX512F__
|
||||
return __builtin_cpu_supports("avx512f") &&
|
||||
__builtin_cpu_supports("avx512dq");
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Initialize SIMD detection (call once at startup)
|
||||
static inline void tav_simd_init(void) {
|
||||
#ifdef __AVX512F__
|
||||
if (cpu_has_avx512f()) {
|
||||
g_simd_level = SIMD_AVX512F;
|
||||
fprintf(stderr, "[TAV] AVX-512 optimisations enabled\n");
|
||||
} else {
|
||||
g_simd_level = SIMD_NONE;
|
||||
fprintf(stderr, "[TAV] AVX-512 not available, using scalar fallback\n");
|
||||
}
|
||||
#else
|
||||
g_simd_level = SIMD_NONE;
|
||||
fprintf(stderr, "[TAV] Compiled without AVX-512 support\n");
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef __AVX512F__
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
// Horizontal sum of 16 floats
|
||||
static inline float _mm512_reduce_add_ps_compat(__m512 v) {
|
||||
__m256 low = _mm512_castps512_ps256(v);
|
||||
__m256 high = _mm512_extractf32x8_ps(v, 1);
|
||||
__m256 sum256 = _mm256_add_ps(low, high);
|
||||
__m128 sum128 = _mm_add_ps(_mm256_castps256_ps128(sum256), _mm256_extractf128_ps(sum256, 1));
|
||||
sum128 = _mm_hadd_ps(sum128, sum128);
|
||||
sum128 = _mm_hadd_ps(sum128, sum128);
|
||||
return _mm_cvtss_f32(sum128);
|
||||
}
|
||||
|
||||
// Clamp helper for vectorised operations
|
||||
static inline __m512 _mm512_clamp_ps(__m512 v, __m512 min_val, __m512 max_val) {
|
||||
return _mm512_min_ps(_mm512_max_ps(v, min_val), max_val);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AVX-512 Optimised 1D DWT Forward Transforms
|
||||
// =============================================================================
|
||||
|
||||
// 5/3 Reversible Forward DWT with AVX-512
|
||||
static inline void dwt_53_forward_1d_avx512(float *data, int length) {
|
||||
if (length < 2) return;
|
||||
|
||||
float *temp = (float*)calloc(length, sizeof(float));
|
||||
int half = (length + 1) / 2;
|
||||
|
||||
// Predict step (high-pass) - vectorised
|
||||
// temp[half + i] = data[2*i+1] - 0.5 * (data[2*i] + data[2*i+2])
|
||||
int i;
|
||||
for (i = 0; i + 16 <= half; i += 16) {
|
||||
__mmask16 valid_mask = 0xFFFF;
|
||||
|
||||
// Check boundary for last iteration
|
||||
for (int j = 0; j < 16; j++) {
|
||||
int idx = 2 * (i + j) + 1;
|
||||
if (idx >= length) {
|
||||
valid_mask &= ~(1 << j);
|
||||
}
|
||||
}
|
||||
|
||||
if (valid_mask == 0) break;
|
||||
|
||||
// Load data[2*i] - stride 2 load
|
||||
float even_curr_vals[16], even_next_vals[16], odd_vals[16];
|
||||
|
||||
for (int j = 0; j < 16; j++) {
|
||||
if (valid_mask & (1 << j)) {
|
||||
even_curr_vals[j] = data[2 * (i + j)];
|
||||
even_next_vals[j] = (2 * (i + j) + 2 < length) ? data[2 * (i + j) + 2] : data[2 * (i + j)];
|
||||
odd_vals[j] = data[2 * (i + j) + 1];
|
||||
} else {
|
||||
even_curr_vals[j] = 0.0f;
|
||||
even_next_vals[j] = 0.0f;
|
||||
odd_vals[j] = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
__m512 even_curr = _mm512_loadu_ps(even_curr_vals);
|
||||
__m512 even_next = _mm512_loadu_ps(even_next_vals);
|
||||
__m512 odd = _mm512_loadu_ps(odd_vals);
|
||||
|
||||
__m512 pred = _mm512_mul_ps(_mm512_add_ps(even_curr, even_next), _mm512_set1_ps(0.5f));
|
||||
__m512 high = _mm512_sub_ps(odd, pred);
|
||||
|
||||
_mm512_mask_storeu_ps(&temp[half + i], valid_mask, high);
|
||||
}
|
||||
|
||||
// Handle remaining elements
|
||||
for (; i < half; i++) {
|
||||
int idx = 2 * i + 1;
|
||||
if (idx < length) {
|
||||
float pred = 0.5f * (data[2 * i] + (2 * i + 2 < length ? data[2 * i + 2] : data[2 * i]));
|
||||
temp[half + i] = data[idx] - pred;
|
||||
}
|
||||
}
|
||||
|
||||
// Update step (low-pass) - vectorised
|
||||
// temp[i] = data[2*i] + 0.25 * (temp[half+i-1] + temp[half+i])
|
||||
for (i = 0; i + 16 <= half; i += 16) {
|
||||
__m512 even = _mm512_loadu_ps(&data[2 * i]); // Load with stride 2 (simplified)
|
||||
|
||||
// Manual gather for strided load
|
||||
float even_vals[16];
|
||||
for (int j = 0; j < 16 && (i + j) < half; j++) {
|
||||
even_vals[j] = data[2 * (i + j)];
|
||||
}
|
||||
even = _mm512_loadu_ps(even_vals);
|
||||
|
||||
// Load high-pass neighbours
|
||||
float high_prev[16], high_curr[16];
|
||||
for (int j = 0; j < 16 && (i + j) < half; j++) {
|
||||
high_prev[j] = ((i + j) > 0) ? temp[half + (i + j) - 1] : 0.0f;
|
||||
high_curr[j] = ((i + j) < half - 1) ? temp[half + (i + j)] : 0.0f;
|
||||
}
|
||||
|
||||
__m512 hp = _mm512_loadu_ps(high_prev);
|
||||
__m512 hc = _mm512_loadu_ps(high_curr);
|
||||
__m512 update = _mm512_mul_ps(_mm512_add_ps(hp, hc), _mm512_set1_ps(0.25f));
|
||||
__m512 low = _mm512_add_ps(even, update);
|
||||
|
||||
__mmask16 store_mask = (i + 16 <= half) ? 0xFFFF : (1 << (half - i)) - 1;
|
||||
_mm512_mask_storeu_ps(&temp[i], store_mask, low);
|
||||
}
|
||||
|
||||
// Handle remaining elements
|
||||
for (; i < half; i++) {
|
||||
float update = 0.25f * ((i > 0 ? temp[half + i - 1] : 0) +
|
||||
(i < half - 1 ? temp[half + i] : 0));
|
||||
temp[i] = data[2 * i] + update;
|
||||
}
|
||||
|
||||
memcpy(data, temp, length * sizeof(float));
|
||||
free(temp);
|
||||
}
|
||||
|
||||
// 9/7 Irreversible Forward DWT with AVX-512
|
||||
static inline void dwt_97_forward_1d_avx512(float *data, int length) {
|
||||
if (length < 2) return;
|
||||
|
||||
int half = (length + 1) / 2;
|
||||
|
||||
// Allocate aligned temp buffer once (64-byte align for cache lines)
|
||||
float *temp = NULL;
|
||||
#if defined(_POSIX_C_SOURCE) || defined(_XOPEN_SOURCE)
|
||||
if (posix_memalign((void**)&temp, 64, (size_t)length * sizeof(float)) != 0) {
|
||||
temp = (float*)malloc((size_t)length * sizeof(float));
|
||||
}
|
||||
#else
|
||||
temp = (float*)aligned_alloc(64, ((size_t)length * sizeof(float) + 63) & ~63);
|
||||
if (!temp) temp = (float*)malloc((size_t)length * sizeof(float));
|
||||
#endif
|
||||
if (!temp) return; // allocation failure: bail out (preserve original behavior could be different)
|
||||
|
||||
// FAST SPLIT: interleave into temp: first half = evens, second half = odds
|
||||
// This is simple, streaming-friendly, and much faster than per-iteration small-array gathers.
|
||||
{
|
||||
float *even = temp;
|
||||
float *odd = temp + half;
|
||||
int i = 0;
|
||||
// process pairs to minimize branches and memory ops
|
||||
for (; i + 1 < length; i += 2) {
|
||||
even[0] = data[i];
|
||||
odd[0] = data[i + 1];
|
||||
++even; ++odd;
|
||||
}
|
||||
if (i < length) { // odd leftover
|
||||
even[0] = data[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Lifting coefficients as vectors
|
||||
const __m512 alpha_vec = _mm512_set1_ps(-1.586134342f);
|
||||
const __m512 beta_vec = _mm512_set1_ps(-0.052980118f);
|
||||
const __m512 gamma_vec = _mm512_set1_ps(0.882911076f);
|
||||
const __m512 delta_vec = _mm512_set1_ps(0.443506852f);
|
||||
const __m512 K_vec = _mm512_set1_ps(1.230174105f);
|
||||
const __m512 invK_vec = _mm512_set1_ps(1.0f / 1.230174105f);
|
||||
|
||||
// Helper variables
|
||||
int i;
|
||||
|
||||
// -----------------------
|
||||
// Step 1: Predict α
|
||||
// d[i] += alpha * (s[i] + s[i+1])
|
||||
// -----------------------
|
||||
if (half > 0) {
|
||||
// handle small or trivial cases
|
||||
if (half == 1) {
|
||||
if (half < length) {
|
||||
temp[half + 0] += -1.586134342f * (temp[0] + temp[0]);
|
||||
}
|
||||
} else {
|
||||
// main vectorised body: ensure s_next loads (i+1) valid -> i <= half-2
|
||||
int limit = (half - 1);
|
||||
int n_full = (limit / 16) * 16; // process up to n_full (multiple of 16)
|
||||
i = 0;
|
||||
for (; i + 32 <= n_full; i += 32) {
|
||||
// unroll 2x (i and i+16)
|
||||
__m512 s0 = _mm512_loadu_ps(&temp[i]);
|
||||
__m512 s0n = _mm512_loadu_ps(&temp[i + 1]);
|
||||
__m512 d0 = _mm512_loadu_ps(&temp[half + i]);
|
||||
__m512 sum0 = _mm512_add_ps(s0, s0n);
|
||||
d0 = _mm512_fmadd_ps(alpha_vec, sum0, d0);
|
||||
_mm512_storeu_ps(&temp[half + i], d0);
|
||||
|
||||
__m512 s1 = _mm512_loadu_ps(&temp[i + 16]);
|
||||
__m512 s1n = _mm512_loadu_ps(&temp[i + 17]);
|
||||
__m512 d1 = _mm512_loadu_ps(&temp[half + i + 16]);
|
||||
__m512 sum1 = _mm512_add_ps(s1, s1n);
|
||||
d1 = _mm512_fmadd_ps(alpha_vec, sum1, d1);
|
||||
_mm512_storeu_ps(&temp[half + i + 16], d1);
|
||||
}
|
||||
for (; i + 16 <= n_full; i += 16) {
|
||||
__m512 s = _mm512_loadu_ps(&temp[i]);
|
||||
__m512 sn = _mm512_loadu_ps(&temp[i + 1]);
|
||||
__m512 d = _mm512_loadu_ps(&temp[half + i]);
|
||||
__m512 sum = _mm512_add_ps(s, sn);
|
||||
d = _mm512_fmadd_ps(alpha_vec, sum, d);
|
||||
_mm512_storeu_ps(&temp[half + i], d);
|
||||
}
|
||||
// scalar remainder up to limit (half-2 -> last vector handled below)
|
||||
for (; i < limit; ++i) {
|
||||
temp[half + i] += -1.586134342f * (temp[i] + temp[i + 1]);
|
||||
}
|
||||
// handle last index i = half-1 (mirror)
|
||||
int last = half - 1;
|
||||
if (half + last < length) {
|
||||
float s_curr = temp[last];
|
||||
float s_next = s_curr;
|
||||
temp[half + last] += -1.586134342f * (s_curr + s_next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Step 2: Update β
|
||||
// s[i] += beta * (d[i-1] + d[i])
|
||||
// -----------------------
|
||||
if (half > 0) {
|
||||
// handle i == 0 separately (d_prev = d_curr for boundary semantics)
|
||||
if (half >= 1) {
|
||||
// i == 0
|
||||
if (half + 0 < length) {
|
||||
float d_curr0 = temp[half + 0];
|
||||
temp[0] += -0.052980118f * (d_curr0 + d_curr0);
|
||||
}
|
||||
}
|
||||
|
||||
if (half > 1) {
|
||||
// main vector loop starting from i = 1 to half-1 (we will write s[i] for i>=1)
|
||||
int start = 1;
|
||||
int limit = half; // exclusive
|
||||
int n_elems = limit - start;
|
||||
int n_full = (n_elems / 16) * 16;
|
||||
i = start;
|
||||
for (; i + 32 <= start + n_full; i += 32) {
|
||||
// unroll 2x
|
||||
__m512 s0 = _mm512_loadu_ps(&temp[i]);
|
||||
__m512 dcurr0 = _mm512_loadu_ps(&temp[half + i]);
|
||||
__m512 dprev0 = _mm512_loadu_ps(&temp[half + i - 1]);
|
||||
__m512 sum0 = _mm512_add_ps(dprev0, dcurr0);
|
||||
s0 = _mm512_fmadd_ps(beta_vec, sum0, s0);
|
||||
_mm512_storeu_ps(&temp[i], s0);
|
||||
|
||||
__m512 s1 = _mm512_loadu_ps(&temp[i + 16]);
|
||||
__m512 dcurr1 = _mm512_loadu_ps(&temp[half + i + 16]);
|
||||
__m512 dprev1 = _mm512_loadu_ps(&temp[half + i + 15]);
|
||||
__m512 sum1 = _mm512_add_ps(dprev1, dcurr1);
|
||||
s1 = _mm512_fmadd_ps(beta_vec, sum1, s1);
|
||||
_mm512_storeu_ps(&temp[i + 16], s1);
|
||||
}
|
||||
for (; i + 16 <= start + n_full; i += 16) {
|
||||
__m512 s = _mm512_loadu_ps(&temp[i]);
|
||||
__m512 dcurr = _mm512_loadu_ps(&temp[half + i]);
|
||||
__m512 dprev = _mm512_loadu_ps(&temp[half + i - 1]);
|
||||
__m512 sum = _mm512_add_ps(dprev, dcurr);
|
||||
s = _mm512_fmadd_ps(beta_vec, sum, s);
|
||||
_mm512_storeu_ps(&temp[i], s);
|
||||
}
|
||||
// scalar remainder
|
||||
for (; i < limit; ++i) {
|
||||
float d_curr = (half + i < length) ? temp[half + i] : 0.0f;
|
||||
float d_prev = (half + i - 1 < length && i > 0) ? temp[half + i - 1] : d_curr;
|
||||
temp[i] += -0.052980118f * (d_prev + d_curr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Step 3: Predict γ
|
||||
// d[i] += gamma * (s[i] + s[i+1])
|
||||
// -----------------------
|
||||
if (half > 0) {
|
||||
if (half == 1) {
|
||||
if (half < length) {
|
||||
temp[half + 0] += 0.882911076f * (temp[0] + temp[0]);
|
||||
}
|
||||
} else {
|
||||
int limit = (half - 1);
|
||||
int n_full = (limit / 16) * 16;
|
||||
i = 0;
|
||||
for (; i + 32 <= n_full; i += 32) {
|
||||
__m512 s0 = _mm512_loadu_ps(&temp[i]);
|
||||
__m512 s0n = _mm512_loadu_ps(&temp[i + 1]);
|
||||
__m512 d0 = _mm512_loadu_ps(&temp[half + i]);
|
||||
__m512 sum0 = _mm512_add_ps(s0, s0n);
|
||||
d0 = _mm512_fmadd_ps(gamma_vec, sum0, d0);
|
||||
_mm512_storeu_ps(&temp[half + i], d0);
|
||||
|
||||
__m512 s1 = _mm512_loadu_ps(&temp[i + 16]);
|
||||
__m512 s1n = _mm512_loadu_ps(&temp[i + 17]);
|
||||
__m512 d1 = _mm512_loadu_ps(&temp[half + i + 16]);
|
||||
__m512 sum1 = _mm512_add_ps(s1, s1n);
|
||||
d1 = _mm512_fmadd_ps(gamma_vec, sum1, d1);
|
||||
_mm512_storeu_ps(&temp[half + i + 16], d1);
|
||||
}
|
||||
for (; i + 16 <= n_full; i += 16) {
|
||||
__m512 s = _mm512_loadu_ps(&temp[i]);
|
||||
__m512 sn = _mm512_loadu_ps(&temp[i + 1]);
|
||||
__m512 d = _mm512_loadu_ps(&temp[half + i]);
|
||||
__m512 sum = _mm512_add_ps(s, sn);
|
||||
d = _mm512_fmadd_ps(gamma_vec, sum, d);
|
||||
_mm512_storeu_ps(&temp[half + i], d);
|
||||
}
|
||||
for (; i < limit; ++i) {
|
||||
temp[half + i] += 0.882911076f * (temp[i] + temp[i + 1]);
|
||||
}
|
||||
// last index mirror
|
||||
int last = half - 1;
|
||||
if (half + last < length) {
|
||||
float s_curr = temp[last];
|
||||
float s_next = s_curr;
|
||||
temp[half + last] += 0.882911076f * (s_curr + s_next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Step 4: Update δ
|
||||
// s[i] += delta * (d[i-1] + d[i])
|
||||
// -----------------------
|
||||
if (half > 0) {
|
||||
// i == 0
|
||||
if (half >= 1) {
|
||||
if (half + 0 < length) {
|
||||
float d_curr0 = temp[half + 0];
|
||||
temp[0] += 0.443506852f * (d_curr0 + d_curr0);
|
||||
}
|
||||
}
|
||||
|
||||
if (half > 1) {
|
||||
int start = 1;
|
||||
int limit = half; // exclusive
|
||||
int n_elems = limit - start;
|
||||
int n_full = (n_elems / 16) * 16;
|
||||
i = start;
|
||||
for (; i + 32 <= start + n_full; i += 32) {
|
||||
__m512 s0 = _mm512_loadu_ps(&temp[i]);
|
||||
__m512 dcurr0 = _mm512_loadu_ps(&temp[half + i]);
|
||||
__m512 dprev0 = _mm512_loadu_ps(&temp[half + i - 1]);
|
||||
__m512 sum0 = _mm512_add_ps(dprev0, dcurr0);
|
||||
s0 = _mm512_fmadd_ps(delta_vec, sum0, s0);
|
||||
_mm512_storeu_ps(&temp[i], s0);
|
||||
|
||||
__m512 s1 = _mm512_loadu_ps(&temp[i + 16]);
|
||||
__m512 dcurr1 = _mm512_loadu_ps(&temp[half + i + 16]);
|
||||
__m512 dprev1 = _mm512_loadu_ps(&temp[half + i + 15]);
|
||||
__m512 sum1 = _mm512_add_ps(dprev1, dcurr1);
|
||||
s1 = _mm512_fmadd_ps(delta_vec, sum1, s1);
|
||||
_mm512_storeu_ps(&temp[i + 16], s1);
|
||||
}
|
||||
for (; i + 16 <= start + n_full; i += 16) {
|
||||
__m512 s = _mm512_loadu_ps(&temp[i]);
|
||||
__m512 dcurr = _mm512_loadu_ps(&temp[half + i]);
|
||||
__m512 dprev = _mm512_loadu_ps(&temp[half + i - 1]);
|
||||
__m512 sum = _mm512_add_ps(dprev, dcurr);
|
||||
s = _mm512_fmadd_ps(delta_vec, sum, s);
|
||||
_mm512_storeu_ps(&temp[i], s);
|
||||
}
|
||||
for (; i < limit; ++i) {
|
||||
float d_curr = (half + i < length) ? temp[half + i] : 0.0f;
|
||||
float d_prev = (half + i - 1 < length && i > 0) ? temp[half + i - 1] : d_curr;
|
||||
temp[i] += 0.443506852f * (d_prev + d_curr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Step 5: Scaling
|
||||
// s *= K, d *= invK
|
||||
// -----------------------
|
||||
// s (first half)
|
||||
{
|
||||
int n_full = (half / 16) * 16;
|
||||
i = 0;
|
||||
for (; i + 32 <= n_full; i += 32) {
|
||||
__m512 s0 = _mm512_loadu_ps(&temp[i]);
|
||||
s0 = _mm512_mul_ps(s0, K_vec);
|
||||
_mm512_storeu_ps(&temp[i], s0);
|
||||
|
||||
__m512 s1 = _mm512_loadu_ps(&temp[i + 16]);
|
||||
s1 = _mm512_mul_ps(s1, K_vec);
|
||||
_mm512_storeu_ps(&temp[i + 16], s1);
|
||||
}
|
||||
for (; i + 16 <= n_full; i += 16) {
|
||||
__m512 s = _mm512_loadu_ps(&temp[i]);
|
||||
s = _mm512_mul_ps(s, K_vec);
|
||||
_mm512_storeu_ps(&temp[i], s);
|
||||
}
|
||||
for (; i < half; ++i) temp[i] *= 1.230174105f;
|
||||
}
|
||||
|
||||
// d (second half)
|
||||
{
|
||||
int dlen = length - half;
|
||||
int n_full = (dlen / 16) * 16;
|
||||
i = 0;
|
||||
for (; i + 32 <= n_full; i += 32) {
|
||||
__m512 d0 = _mm512_loadu_ps(&temp[half + i]);
|
||||
d0 = _mm512_mul_ps(d0, invK_vec);
|
||||
_mm512_storeu_ps(&temp[half + i], d0);
|
||||
|
||||
__m512 d1 = _mm512_loadu_ps(&temp[half + i + 16]);
|
||||
d1 = _mm512_mul_ps(d1, invK_vec);
|
||||
_mm512_storeu_ps(&temp[half + i + 16], d1);
|
||||
}
|
||||
for (; i + 16 <= n_full; i += 16) {
|
||||
__m512 d = _mm512_loadu_ps(&temp[half + i]);
|
||||
d = _mm512_mul_ps(d, invK_vec);
|
||||
_mm512_storeu_ps(&temp[half + i], d);
|
||||
}
|
||||
for (; i < dlen; ++i) {
|
||||
if (half + i < length) temp[half + i] /= 1.230174105f;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy back and free
|
||||
memcpy(data, temp, (size_t)length * sizeof(float));
|
||||
free(temp);
|
||||
}
|
||||
|
||||
// Haar Forward DWT with AVX-512
|
||||
static inline void dwt_haar_forward_1d_avx512(float *data, int length) {
|
||||
if (length < 2) return;
|
||||
|
||||
float *temp = (float*)malloc(length * sizeof(float));
|
||||
int half = (length + 1) / 2;
|
||||
|
||||
const __m512 half_vec = _mm512_set1_ps(0.5f);
|
||||
|
||||
// Process 16 pairs at a time
|
||||
int i;
|
||||
for (i = 0; i + 16 <= half; i += 16) {
|
||||
__mmask16 valid_mask = 0xFFFF;
|
||||
|
||||
float even_vals[16], odd_vals[16];
|
||||
for (int j = 0; j < 16; j++) {
|
||||
even_vals[j] = data[2 * (i + j)];
|
||||
if (2 * (i + j) + 1 < length) {
|
||||
odd_vals[j] = data[2 * (i + j) + 1];
|
||||
} else {
|
||||
odd_vals[j] = even_vals[j];
|
||||
valid_mask &= ~(1 << j);
|
||||
}
|
||||
}
|
||||
|
||||
__m512 even = _mm512_loadu_ps(even_vals);
|
||||
__m512 odd = _mm512_loadu_ps(odd_vals);
|
||||
|
||||
// Low-pass: (even + odd) / 2
|
||||
__m512 low = _mm512_mul_ps(_mm512_add_ps(even, odd), half_vec);
|
||||
// High-pass: (even - odd) / 2
|
||||
__m512 high = _mm512_mul_ps(_mm512_sub_ps(even, odd), half_vec);
|
||||
|
||||
_mm512_storeu_ps(&temp[i], low);
|
||||
_mm512_mask_storeu_ps(&temp[half + i], valid_mask, high);
|
||||
}
|
||||
|
||||
// Remaining scalar
|
||||
for (; i < half; i++) {
|
||||
if (2 * i + 1 < length) {
|
||||
temp[i] = (data[2 * i] + data[2 * i + 1]) / 2.0f;
|
||||
temp[half + i] = (data[2 * i] - data[2 * i + 1]) / 2.0f;
|
||||
} else {
|
||||
temp[i] = data[2 * i];
|
||||
if (half + i < length) {
|
||||
temp[half + i] = 0.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memcpy(data, temp, length * sizeof(float));
|
||||
free(temp);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AVX-512 Optimised Quantisation Functions
|
||||
// =============================================================================
|
||||
|
||||
static inline void quantise_dwt_coefficients_avx512(
|
||||
float *coeffs, int16_t *quantised, int size,
|
||||
float effective_q, float dead_zone_threshold,
|
||||
int width, int height, int decomp_levels, int is_chroma,
|
||||
int (*get_subband_level)(int, int, int, int),
|
||||
int (*get_subband_type)(int, int, int, int)
|
||||
) {
|
||||
const __m512 q_vec = _mm512_set1_ps(effective_q);
|
||||
const __m512 inv_q_vec = _mm512_set1_ps(1.0f / effective_q);
|
||||
const __m512 half_vec = _mm512_set1_ps(0.5f);
|
||||
const __m512 nhalf_vec = _mm512_set1_ps(-0.5f);
|
||||
const __m512 zero_vec = _mm512_setzero_ps();
|
||||
const __m512i min_i32 = _mm512_set1_epi32(-32768);
|
||||
const __m512i max_i32 = _mm512_set1_epi32(32767);
|
||||
|
||||
int i;
|
||||
for (i = 0; i + 16 <= size; i += 16) {
|
||||
__m512 coeff = _mm512_loadu_ps(&coeffs[i]);
|
||||
__m512 quant = _mm512_mul_ps(coeff, inv_q_vec);
|
||||
|
||||
// Dead-zone handling (simplified - full version needs per-coeff logic)
|
||||
if (dead_zone_threshold > 0.0f && !is_chroma) {
|
||||
__m512 threshold_vec = _mm512_set1_ps(dead_zone_threshold);
|
||||
__m512 abs_quant = _mm512_abs_ps(quant);
|
||||
__mmask16 dead_mask = _mm512_cmp_ps_mask(abs_quant, threshold_vec, _CMP_LE_OQ);
|
||||
quant = _mm512_mask_blend_ps(dead_mask, quant, zero_vec);
|
||||
}
|
||||
|
||||
// Manual rounding to match scalar behaviour (round away from zero)
|
||||
// First add 0.5 or -0.5 based on sign
|
||||
__mmask16 pos_mask = _mm512_cmp_ps_mask(quant, zero_vec, _CMP_GE_OQ);
|
||||
__m512 round_val = _mm512_mask_blend_ps(pos_mask, nhalf_vec, half_vec);
|
||||
quant = _mm512_add_ps(quant, round_val);
|
||||
|
||||
// Now truncate to int32 (this matches scalar (int32_t) cast after adding 0.5)
|
||||
__m512i quant_i32 = _mm512_cvttps_epi32(quant); // cvtt = truncate (round toward zero)
|
||||
quant_i32 = _mm512_max_epi32(quant_i32, min_i32);
|
||||
quant_i32 = _mm512_min_epi32(quant_i32, max_i32);
|
||||
|
||||
// Pack to int16 (AVX-512 has cvtsepi32_epi16)
|
||||
__m256i quant_i16 = _mm512_cvtsepi32_epi16(quant_i32);
|
||||
_mm256_storeu_si256((__m256i*)&quantised[i], quant_i16);
|
||||
}
|
||||
|
||||
// Remaining scalar
|
||||
for (; i < size; i++) {
|
||||
float quantised_val = coeffs[i] / effective_q;
|
||||
|
||||
// Dead-zone (simplified)
|
||||
if (dead_zone_threshold > 0.0f && !is_chroma) {
|
||||
if (fabsf(quantised_val) <= dead_zone_threshold) {
|
||||
quantised_val = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
int32_t val = (int32_t)(quantised_val + (quantised_val >= 0 ? 0.5f : -0.5f));
|
||||
quantised[i] = (int16_t)((val < -32768) ? -32768 : (val > 32767 ? 32767 : val));
|
||||
}
|
||||
}
|
||||
|
||||
// Perceptual quantisation with per-coefficient weighting
|
||||
static inline void quantise_dwt_coefficients_perceptual_avx512(
|
||||
float *coeffs, int16_t *quantised, int size,
|
||||
float *weights, // Pre-computed per-coefficient weights
|
||||
float base_quantiser
|
||||
) {
|
||||
const __m512 base_q_vec = _mm512_set1_ps(base_quantiser);
|
||||
const __m512 half_vec = _mm512_set1_ps(0.5f);
|
||||
const __m512 nhalf_vec = _mm512_set1_ps(-0.5f);
|
||||
const __m512 zero_vec = _mm512_setzero_ps();
|
||||
const __m512i min_i32 = _mm512_set1_epi32(-32768);
|
||||
const __m512i max_i32 = _mm512_set1_epi32(32767);
|
||||
|
||||
int i;
|
||||
for (i = 0; i + 16 <= size; i += 16) {
|
||||
__m512 coeff = _mm512_loadu_ps(&coeffs[i]);
|
||||
__m512 weight = _mm512_loadu_ps(&weights[i]);
|
||||
|
||||
// effective_q = base_q * weight
|
||||
__m512 effective_q = _mm512_mul_ps(base_q_vec, weight);
|
||||
__m512 quant = _mm512_div_ps(coeff, effective_q);
|
||||
|
||||
// Manual rounding to match scalar behaviour
|
||||
__mmask16 pos_mask = _mm512_cmp_ps_mask(quant, zero_vec, _CMP_GE_OQ);
|
||||
__m512 round_val = _mm512_mask_blend_ps(pos_mask, nhalf_vec, half_vec);
|
||||
quant = _mm512_add_ps(quant, round_val);
|
||||
|
||||
// Truncate to int32 (matches scalar cast after rounding)
|
||||
__m512i quant_i32 = _mm512_cvttps_epi32(quant);
|
||||
quant_i32 = _mm512_max_epi32(quant_i32, min_i32);
|
||||
quant_i32 = _mm512_min_epi32(quant_i32, max_i32);
|
||||
|
||||
__m256i quant_i16 = _mm512_cvtsepi32_epi16(quant_i32);
|
||||
_mm256_storeu_si256((__m256i*)&quantised[i], quant_i16);
|
||||
}
|
||||
|
||||
// Remaining scalar
|
||||
for (; i < size; i++) {
|
||||
float effective_q = base_quantiser * weights[i];
|
||||
float quantised_val = coeffs[i] / effective_q;
|
||||
int32_t val = (int32_t)(quantised_val + (quantised_val >= 0 ? 0.5f : -0.5f));
|
||||
quantised[i] = (int16_t)((val < -32768) ? -32768 : (val > 32767 ? 32767 : val));
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AVX-512 Optimised Dequantisation Functions
|
||||
// =============================================================================
|
||||
|
||||
// Basic dequantisation: quantised[i] * effective_q
|
||||
static inline void dequantise_dwt_coefficients_avx512(
|
||||
const int16_t *quantised, float *coeffs, int size,
|
||||
float effective_q
|
||||
) {
|
||||
const __m512 q_vec = _mm512_set1_ps(effective_q);
|
||||
|
||||
int i;
|
||||
for (i = 0; i + 16 <= size; i += 16) {
|
||||
// Load 16 int16 values
|
||||
__m256i quant_i16 = _mm256_loadu_si256((__m256i*)&quantised[i]);
|
||||
|
||||
// Convert int16 to int32
|
||||
__m512i quant_i32 = _mm512_cvtepi16_epi32(quant_i16);
|
||||
|
||||
// Convert int32 to float
|
||||
__m512 quant_f32 = _mm512_cvtepi32_ps(quant_i32);
|
||||
|
||||
// Multiply by quantiser
|
||||
__m512 dequant = _mm512_mul_ps(quant_f32, q_vec);
|
||||
|
||||
_mm512_storeu_ps(&coeffs[i], dequant);
|
||||
}
|
||||
|
||||
// Remaining scalar
|
||||
for (; i < size; i++) {
|
||||
coeffs[i] = (float)quantised[i] * effective_q;
|
||||
}
|
||||
}
|
||||
|
||||
// Perceptual dequantisation with per-coefficient weights
|
||||
static inline void dequantise_dwt_coefficients_perceptual_avx512(
|
||||
const int16_t *quantised, float *coeffs, int size,
|
||||
const float *weights, float base_quantiser
|
||||
) {
|
||||
const __m512 base_q_vec = _mm512_set1_ps(base_quantiser);
|
||||
|
||||
int i;
|
||||
for (i = 0; i + 16 <= size; i += 16) {
|
||||
// Load 16 int16 values
|
||||
__m256i quant_i16 = _mm256_loadu_si256((__m256i*)&quantised[i]);
|
||||
|
||||
// Convert int16 → int32 → float
|
||||
__m512i quant_i32 = _mm512_cvtepi16_epi32(quant_i16);
|
||||
__m512 quant_f32 = _mm512_cvtepi32_ps(quant_i32);
|
||||
|
||||
// Load weights
|
||||
__m512 weight = _mm512_loadu_ps(&weights[i]);
|
||||
|
||||
// effective_q = base_q * weight
|
||||
__m512 effective_q = _mm512_mul_ps(base_q_vec, weight);
|
||||
|
||||
// dequant = quantised * effective_q
|
||||
__m512 dequant = _mm512_mul_ps(quant_f32, effective_q);
|
||||
|
||||
_mm512_storeu_ps(&coeffs[i], dequant);
|
||||
}
|
||||
|
||||
// Remaining scalar
|
||||
for (; i < size; i++) {
|
||||
float effective_q = base_quantiser * weights[i];
|
||||
coeffs[i] = (float)quantised[i] * effective_q;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AVX-512 Optimised RGB to YCoCg Conversion
|
||||
// =============================================================================
|
||||
|
||||
static inline void rgb_to_ycocg_avx512(const uint8_t *rgb, float *y, float *co, float *cg, int width, int height) {
|
||||
const int total_pixels = width * height;
|
||||
const __m512 half_vec = _mm512_set1_ps(0.5f);
|
||||
|
||||
int i;
|
||||
// Process 16 pixels at a time (48 bytes of RGB data)
|
||||
for (i = 0; i + 16 <= total_pixels; i += 16) {
|
||||
// Load 16 RGB triplets (48 bytes)
|
||||
// We need to deinterleave R, G, B channels
|
||||
|
||||
// Manual load and deinterleave (AVX-512 doesn't have direct RGB deinterleave)
|
||||
float r_vals[16], g_vals[16], b_vals[16];
|
||||
for (int j = 0; j < 16; j++) {
|
||||
r_vals[j] = (float)rgb[(i + j) * 3 + 0];
|
||||
g_vals[j] = (float)rgb[(i + j) * 3 + 1];
|
||||
b_vals[j] = (float)rgb[(i + j) * 3 + 2];
|
||||
}
|
||||
|
||||
__m512 r = _mm512_loadu_ps(r_vals);
|
||||
__m512 g = _mm512_loadu_ps(g_vals);
|
||||
__m512 b = _mm512_loadu_ps(b_vals);
|
||||
|
||||
// YCoCg-R transform:
|
||||
// co = r - b
|
||||
// tmp = b + co * 0.5
|
||||
// cg = g - tmp
|
||||
// y = tmp + cg * 0.5
|
||||
|
||||
__m512 co_vec = _mm512_sub_ps(r, b);
|
||||
__m512 tmp = _mm512_fmadd_ps(co_vec, half_vec, b); // tmp = b + co * 0.5
|
||||
__m512 cg_vec = _mm512_sub_ps(g, tmp);
|
||||
__m512 y_vec = _mm512_fmadd_ps(cg_vec, half_vec, tmp); // y = tmp + cg * 0.5
|
||||
|
||||
_mm512_storeu_ps(&y[i], y_vec);
|
||||
_mm512_storeu_ps(&co[i], co_vec);
|
||||
_mm512_storeu_ps(&cg[i], cg_vec);
|
||||
}
|
||||
|
||||
// Remaining pixels (scalar)
|
||||
for (; i < total_pixels; i++) {
|
||||
const float r = rgb[i * 3 + 0];
|
||||
const float g = rgb[i * 3 + 1];
|
||||
const float b = rgb[i * 3 + 2];
|
||||
|
||||
co[i] = r - b;
|
||||
const float tmp = b + co[i] * 0.5f;
|
||||
cg[i] = g - tmp;
|
||||
y[i] = tmp + cg[i] * 0.5f;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AVX-512 Optimised 2D DWT with Gather/Scatter
|
||||
// =============================================================================
|
||||
|
||||
// Optimised column extraction using gather
|
||||
static inline void dwt_2d_extract_column_avx512(
|
||||
const float *tile_data, float *column,
|
||||
int x, int width, int height
|
||||
) {
|
||||
// Create gather indices for column extraction
|
||||
// indices[i] = (i * width + x)
|
||||
|
||||
int y;
|
||||
for (y = 0; y + 16 <= height; y += 16) {
|
||||
// Build gather indices
|
||||
int indices[16];
|
||||
for (int j = 0; j < 16; j++) {
|
||||
indices[j] = (y + j) * width + x;
|
||||
}
|
||||
|
||||
__m512i vindex = _mm512_loadu_si512((__m512i*)indices);
|
||||
__m512 col_data = _mm512_i32gather_ps(vindex, tile_data, 4);
|
||||
_mm512_storeu_ps(&column[y], col_data);
|
||||
}
|
||||
|
||||
// Remaining scalar
|
||||
for (; y < height; y++) {
|
||||
column[y] = tile_data[y * width + x];
|
||||
}
|
||||
}
|
||||
|
||||
// Optimised column insertion using scatter
|
||||
static inline void dwt_2d_insert_column_avx512(
|
||||
float *tile_data, const float *column,
|
||||
int x, int width, int height
|
||||
) {
|
||||
int y;
|
||||
for (y = 0; y + 16 <= height; y += 16) {
|
||||
// Build scatter indices
|
||||
int indices[16];
|
||||
for (int j = 0; j < 16; j++) {
|
||||
indices[j] = (y + j) * width + x;
|
||||
}
|
||||
|
||||
__m512i vindex = _mm512_loadu_si512((__m512i*)indices);
|
||||
__m512 col_data = _mm512_loadu_ps(&column[y]);
|
||||
_mm512_i32scatter_ps(tile_data, vindex, col_data, 4);
|
||||
}
|
||||
|
||||
// Remaining scalar
|
||||
for (; y < height; y++) {
|
||||
tile_data[y * width + x] = column[y];
|
||||
}
|
||||
}
|
||||
|
||||
#endif // __AVX512F__
|
||||
|
||||
#endif // TAV_AVX512_H
|
||||
@@ -1,295 +0,0 @@
|
||||
/**
|
||||
* TAV Encoder Library - Public API
|
||||
*
|
||||
* High-level interface for encoding video using the TSVM Advanced Video (TAV) codec.
|
||||
* Supports GOP-based encoding with internal multi-threading for optimal performance.
|
||||
*
|
||||
* Created by CuriousTorvald and Claude on 2025-12-03.
|
||||
*/
|
||||
|
||||
#ifndef TAV_ENCODER_LIB_H
|
||||
#define TAV_ENCODER_LIB_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// =============================================================================
|
||||
// Opaque Encoder Context
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* TAV encoder context - opaque to users.
|
||||
* Created with tav_encoder_create(), freed with tav_encoder_free().
|
||||
*/
|
||||
typedef struct tav_encoder_context tav_encoder_context_t;
|
||||
|
||||
// =============================================================================
|
||||
// Configuration Structures
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Video encoding parameters.
|
||||
*/
|
||||
typedef struct {
|
||||
// === Video Dimensions ===
|
||||
int width; // Frame width (must be even)
|
||||
int height; // Frame height (must be even)
|
||||
int fps_num; // Framerate numerator (e.g., 60 for 60fps)
|
||||
int fps_den; // Framerate denominator (e.g., 1 for 60/1)
|
||||
|
||||
// === Wavelet Configuration ===
|
||||
int wavelet_type; // Spatial wavelet: 0=CDF 5/3, 1=CDF 9/7 (default), 2=CDF 13/7, 16=DD-4, 255=Haar
|
||||
int temporal_wavelet; // Temporal wavelet: 0=Haar, 1=CDF 5/3 (default for smooth motion)
|
||||
int decomp_levels; // Spatial DWT levels (0=auto, typically 6)
|
||||
int temporal_levels; // Temporal DWT levels (0=auto, typically 2 for 8-frame GOPs)
|
||||
|
||||
// === Color Space ===
|
||||
int channel_layout; // 0=YCoCg-R (default), 1=ICtCp (for HDR/BT.2100 sources)
|
||||
int perceptual_tuning; // 1=enable HVS perceptual quantization (default), 0=uniform
|
||||
|
||||
// === GOP Configuration ===
|
||||
int enable_temporal_dwt; // 1=enable 3D DWT GOP encoding (default), 0=intra-only I-frames
|
||||
int gop_size; // Frames per GOP (8, 16, or 24; 0=auto based on framerate)
|
||||
int enable_two_pass; // 1=enable two-pass with scene change detection (default), 0=single-pass
|
||||
|
||||
// === Quality Control ===
|
||||
int quality_level;
|
||||
int quantiser_y; // Luma quantiser (0-255, indexed against QLUT)
|
||||
int quantiser_co; // Orange chrominance quantiser (0-255, indexed against QLUT)
|
||||
int quantiser_cg; // Green chrominance quantiser (0-255, indexed against QLUT)
|
||||
float dead_zone_threshold; // Dead-zone quantization threshold (0.0=disabled, 0.6-1.5 typical)
|
||||
|
||||
// === Entropy Coding ===
|
||||
int entropy_coder; // 0=Twobitmap (default), 1=EZBC (better for high-quality)
|
||||
int zstd_level; // Zstd compression level (3-22, default: 7)
|
||||
|
||||
// === Multi-threading ===
|
||||
int num_threads; // Worker threads (0=single-threaded, -1=auto, 1-16=explicit)
|
||||
|
||||
// === Encoder Presets ===
|
||||
int encoder_preset; // Preset flags: 0x01=sports (finer temporal quant), 0x02=anime (disable grain)
|
||||
|
||||
// === Advanced Options ===
|
||||
int verbose; // 1=enable debug output, 0=quiet (default)
|
||||
int monoblock; // -1=auto (based on dimensions), 0=force tiled, 1=force monoblock
|
||||
|
||||
} tav_encoder_params_t;
|
||||
|
||||
/**
|
||||
* Initialize encoder parameters with default values.
|
||||
*
|
||||
* @param params Parameter structure to initialize
|
||||
* @param width Frame width
|
||||
* @param height Frame height
|
||||
*/
|
||||
void tav_encoder_params_init(tav_encoder_params_t *params, int width, int height);
|
||||
|
||||
/**
|
||||
* Encoder output packet.
|
||||
* Contains encoded video or audio data.
|
||||
*/
|
||||
typedef struct {
|
||||
uint8_t *data; // Packet data (owned by encoder, valid until next encode/flush)
|
||||
size_t size; // Packet size in bytes
|
||||
uint8_t packet_type; // TAV packet type (0x10=I-frame, 0x12=GOP, 0x24=audio, etc.)
|
||||
int frame_number; // Frame number (for video packets)
|
||||
int is_video; // 1=video packet, 0=audio packet
|
||||
} tav_encoder_packet_t;
|
||||
|
||||
// =============================================================================
|
||||
// Encoder Lifecycle
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create TAV encoder context.
|
||||
*
|
||||
* Allocates internal buffers, initializes thread pool (if multi-threading enabled),
|
||||
* and prepares encoder for frame submission.
|
||||
*
|
||||
* @param params Encoder parameters (copied internally)
|
||||
* @return Encoder context, or NULL on failure
|
||||
*/
|
||||
tav_encoder_context_t *tav_encoder_create(const tav_encoder_params_t *params);
|
||||
|
||||
/**
|
||||
* Free TAV encoder context.
|
||||
*
|
||||
* Shuts down thread pool, frees all buffers and resources.
|
||||
* Any unflushed frames in the GOP buffer will be lost.
|
||||
*
|
||||
* @param ctx Encoder context
|
||||
*/
|
||||
void tav_encoder_free(tav_encoder_context_t *ctx);
|
||||
|
||||
/**
|
||||
* Get last error message.
|
||||
*
|
||||
* @param ctx Encoder context
|
||||
* @return Error message string (valid until next encode operation)
|
||||
*/
|
||||
const char *tav_encoder_get_error(tav_encoder_context_t *ctx);
|
||||
|
||||
/**
|
||||
* Get encoder parameters (with calculated values).
|
||||
* After context creation, params will contain actual values used
|
||||
* (e.g., auto-calculated decomp_levels, gop_size).
|
||||
*
|
||||
* @param ctx Encoder context
|
||||
* @param params Output parameters structure
|
||||
*/
|
||||
void tav_encoder_get_params(tav_encoder_context_t *ctx, tav_encoder_params_t *params);
|
||||
|
||||
/**
|
||||
* DEBUG: Validate encoder context integrity
|
||||
* Returns 1 if context appears valid, 0 otherwise
|
||||
*/
|
||||
int tav_encoder_validate_context(tav_encoder_context_t *ctx);
|
||||
|
||||
// =============================================================================
|
||||
// Video Encoding
|
||||
// =============================================================================
|
||||
|
||||
/*
|
||||
* DEPRECATED: tav_encoder_encode_frame() and tav_encoder_flush() have been
|
||||
* removed. Use tav_encoder_encode_gop() instead, which works for both
|
||||
* single-threaded and multi-threaded modes. The CLI should buffer frames
|
||||
* and call encode_gop() when a full GOP is ready.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode a complete GOP (Group of Pictures) directly.
|
||||
*
|
||||
* This function is STATELESS and THREAD-SAFE with separate contexts.
|
||||
* Perfect for multithreaded encoding from CLI:
|
||||
* - Each thread creates its own encoder context
|
||||
* - Each thread calls encode_gop() with a batch of frames
|
||||
* - No shared state, no locking needed
|
||||
*
|
||||
* Example multithreaded usage:
|
||||
* ```c
|
||||
* // Worker thread function
|
||||
* void* worker(void* arg) {
|
||||
* work_item_t* item = (work_item_t*)arg;
|
||||
*
|
||||
* // Create thread-local encoder context
|
||||
* tav_encoder_context_t* ctx = tav_encoder_create(&shared_params);
|
||||
*
|
||||
* // Encode this GOP
|
||||
* tav_encoder_packet_t* packet;
|
||||
* tav_encoder_encode_gop(ctx, item->frames, item->num_frames,
|
||||
* item->frame_numbers, &packet);
|
||||
*
|
||||
* // Store packet in output queue
|
||||
* queue_push(output_queue, packet);
|
||||
*
|
||||
* tav_encoder_free(ctx);
|
||||
* return NULL;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param ctx Encoder context (one per thread)
|
||||
* @param rgb_frames Array of RGB24 frames [frame][width*height*3]
|
||||
* @param num_frames Number of frames in GOP (1-24)
|
||||
* @param frame_numbers Frame indices for timecodes (can be NULL)
|
||||
* @param packet Output packet pointer
|
||||
* @return 1 if packet ready, -1 on error
|
||||
*/
|
||||
int tav_encoder_encode_gop(tav_encoder_context_t *ctx,
|
||||
const uint8_t **rgb_frames,
|
||||
int num_frames,
|
||||
const int *frame_numbers,
|
||||
tav_encoder_packet_t **packet);
|
||||
|
||||
/**
|
||||
* Free a packet returned by encode_frame(), flush(), or encode_gop().
|
||||
*
|
||||
* @param packet Packet to free (can be NULL)
|
||||
*/
|
||||
void tav_encoder_free_packet(tav_encoder_packet_t *packet);
|
||||
|
||||
// =============================================================================
|
||||
// Audio Encoding (Optional)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Encode audio samples (TAD codec).
|
||||
*
|
||||
* Audio is encoded synchronously and returned immediately.
|
||||
* For TAV muxing: interleave audio packets with video packets by frame PTS.
|
||||
*
|
||||
* @param ctx Encoder context
|
||||
* @param pcm_samples PCM32f stereo samples (interleaved: L,R,L,R,...), num_samples×2 floats
|
||||
* @param num_samples Number of samples per channel
|
||||
* @param packet Output packet pointer
|
||||
* @return 1 if packet ready, -1 on error
|
||||
*/
|
||||
int tav_encoder_encode_audio(tav_encoder_context_t *ctx,
|
||||
const float *pcm_samples,
|
||||
size_t num_samples,
|
||||
tav_encoder_packet_t **packet);
|
||||
|
||||
// =============================================================================
|
||||
// Statistics and Info
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get encoding statistics.
|
||||
*/
|
||||
typedef struct {
|
||||
int64_t frames_encoded; // Total frames encoded
|
||||
int64_t gops_encoded; // Total GOPs encoded
|
||||
size_t total_bytes; // Total bytes output (video + audio)
|
||||
size_t video_bytes; // Video bytes
|
||||
size_t audio_bytes; // Audio bytes
|
||||
double avg_bitrate_kbps; // Average bitrate (kbps)
|
||||
double encoding_fps; // Encoding speed (frames/sec)
|
||||
} tav_encoder_stats_t;
|
||||
|
||||
/**
|
||||
* Get encoding statistics.
|
||||
*
|
||||
* @param ctx Encoder context
|
||||
* @param stats Output statistics structure
|
||||
*/
|
||||
void tav_encoder_get_stats(tav_encoder_context_t *ctx, tav_encoder_stats_t *stats);
|
||||
|
||||
// =============================================================================
|
||||
// TAV Packet Types (for reference)
|
||||
// =============================================================================
|
||||
|
||||
#define TAV_PACKET_IFRAME 0x10 // I-frame (intra-only, single frame)
|
||||
#define TAV_PACKET_PFRAME 0x11 // P-frame (delta from previous)
|
||||
#define TAV_PACKET_GOP_UNIFIED 0x12 // GOP unified (3D DWT, multiple frames)
|
||||
#define TAV_PACKET_AUDIO_TAD 0x24 // TAD audio (DWT-based perceptual codec)
|
||||
#define TAV_PACKET_AUDIO_PCM8 0x20 // PCM8 audio (legacy)
|
||||
#define TAV_PACKET_LOOP_START 0xF0 // Loop point start (no payload)
|
||||
#define TAV_PACKET_GOP_SYNC 0xFC // GOP sync (frame count marker)
|
||||
#define TAV_PACKET_TIMECODE 0xFD // Timecode metadata
|
||||
#define TAV_PACKET_SYNC 0xFF // Sync packet (no payload)
|
||||
|
||||
// =============================================================================
|
||||
// Tile Settings (for multi-tile mode)
|
||||
// =============================================================================
|
||||
|
||||
#define TAV_TILE_SIZE_X 640 // Base tile width
|
||||
#define TAV_TILE_SIZE_Y 540 // Base tile height
|
||||
#define TAV_DWT_FILTER_HALF_SUPPORT 4 // For 9/7 filter (filter lengths 9,7 → L=4)
|
||||
#define TAV_TILE_MARGIN_LEVELS 3 // Use margin for 3 levels: 4 * (2^3) = 32px
|
||||
#define TAV_TILE_MARGIN (TAV_DWT_FILTER_HALF_SUPPORT * (1 << TAV_TILE_MARGIN_LEVELS)) // 32px
|
||||
#define TAV_PADDED_TILE_SIZE_X (TAV_TILE_SIZE_X + 2 * TAV_TILE_MARGIN) // 704
|
||||
#define TAV_PADDED_TILE_SIZE_Y (TAV_TILE_SIZE_Y + 2 * TAV_TILE_MARGIN) // 604
|
||||
|
||||
// Monoblock threshold: D1 PAL resolution (720x576)
|
||||
// If width > 720 OR height > 576, automatically switch to tiled mode
|
||||
#define TAV_MONOBLOCK_MAX_WIDTH 720
|
||||
#define TAV_MONOBLOCK_MAX_HEIGHT 576
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // TAV_ENCODER_LIB_H
|
||||
@@ -1,275 +0,0 @@
|
||||
/*
|
||||
* TAV SIMD Function Dispatcher
|
||||
*
|
||||
* This file provides runtime CPU detection and function pointer dispatch
|
||||
* for SIMD-optimized versions of performance-critical TAV encoder functions.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Include this header after defining all scalar functions
|
||||
* 2. Call tav_simd_init() once at encoder initialization
|
||||
* 3. Use function pointers (e.g., dwt_53_forward_1d_ptr) throughout code
|
||||
*
|
||||
* The dispatcher will automatically select AVX-512, AVX2, or scalar versions
|
||||
* based on runtime CPU capabilities.
|
||||
*/
|
||||
|
||||
#ifndef TAV_SIMD_DISPATCH_H
|
||||
#define TAV_SIMD_DISPATCH_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// =============================================================================
|
||||
// Function Pointer Types
|
||||
// =============================================================================
|
||||
|
||||
// 1D DWT function pointer types
|
||||
typedef void (*dwt_1d_func_t)(float *data, int length);
|
||||
|
||||
// Quantization function pointer types
|
||||
typedef void (*quantise_basic_func_t)(
|
||||
float *coeffs, int16_t *quantised, int size,
|
||||
float effective_q, float dead_zone_threshold,
|
||||
int width, int height, int decomp_levels, int is_chroma,
|
||||
int (*get_subband_level)(int, int, int, int),
|
||||
int (*get_subband_type)(int, int, int, int)
|
||||
);
|
||||
|
||||
typedef void (*quantise_perceptual_func_t)(
|
||||
float *coeffs, int16_t *quantised, int size,
|
||||
float *weights, float base_quantiser
|
||||
);
|
||||
|
||||
// Color conversion function pointer type
|
||||
typedef void (*rgb_to_ycocg_func_t)(
|
||||
const uint8_t *rgb, float *y, float *co, float *cg,
|
||||
int width, int height
|
||||
);
|
||||
|
||||
// 2D DWT column operations
|
||||
typedef void (*dwt_2d_column_extract_func_t)(
|
||||
const float *tile_data, float *column,
|
||||
int x, int width, int height
|
||||
);
|
||||
|
||||
typedef void (*dwt_2d_column_insert_func_t)(
|
||||
float *tile_data, const float *column,
|
||||
int x, int width, int height
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Global Function Pointers (initialized by tav_simd_init)
|
||||
// =============================================================================
|
||||
|
||||
// DWT 1D transforms
|
||||
static dwt_1d_func_t dwt_53_forward_1d_ptr = NULL;
|
||||
static dwt_1d_func_t dwt_97_forward_1d_ptr = NULL;
|
||||
static dwt_1d_func_t dwt_haar_forward_1d_ptr = NULL;
|
||||
static dwt_1d_func_t dwt_53_inverse_1d_ptr = NULL;
|
||||
static dwt_1d_func_t dwt_haar_inverse_1d_ptr = NULL;
|
||||
|
||||
// Quantization
|
||||
static quantise_basic_func_t quantise_dwt_coefficients_ptr = NULL;
|
||||
static quantise_perceptual_func_t quantise_dwt_coefficients_perceptual_ptr = NULL;
|
||||
|
||||
// Color conversion
|
||||
static rgb_to_ycocg_func_t rgb_to_ycocg_ptr = NULL;
|
||||
|
||||
// 2D DWT column operations
|
||||
static dwt_2d_column_extract_func_t dwt_2d_extract_column_ptr = NULL;
|
||||
static dwt_2d_column_insert_func_t dwt_2d_insert_column_ptr = NULL;
|
||||
|
||||
// =============================================================================
|
||||
// SIMD Capability Detection
|
||||
// =============================================================================
|
||||
|
||||
typedef enum {
|
||||
SIMD_NONE = 0,
|
||||
SIMD_AVX512F = 1,
|
||||
SIMD_AVX2 = 2,
|
||||
SIMD_SSE42 = 3
|
||||
} simd_level_t;
|
||||
|
||||
static simd_level_t detected_simd_level = SIMD_NONE;
|
||||
|
||||
static inline simd_level_t detect_simd_capabilities(void) {
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
// Use GCC/Clang built-in CPU detection
|
||||
if (!__builtin_cpu_supports("sse4.2")) {
|
||||
return SIMD_NONE;
|
||||
}
|
||||
|
||||
#ifdef __AVX512F__
|
||||
if (__builtin_cpu_supports("avx512f") &&
|
||||
__builtin_cpu_supports("avx512dq") &&
|
||||
__builtin_cpu_supports("avx512bw") &&
|
||||
__builtin_cpu_supports("avx512vl")) {
|
||||
return SIMD_AVX512F;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef __AVX2__
|
||||
if (__builtin_cpu_supports("avx2")) {
|
||||
return SIMD_AVX2;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (__builtin_cpu_supports("sse4.2")) {
|
||||
return SIMD_SSE42;
|
||||
}
|
||||
#endif
|
||||
|
||||
return SIMD_NONE;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Scalar Fallback Wrappers
|
||||
// =============================================================================
|
||||
|
||||
// These wrappers adapt the scalar functions to match function pointer signatures
|
||||
|
||||
static void quantise_dwt_coefficients_scalar_wrapper(
|
||||
float *coeffs, int16_t *quantised, int size,
|
||||
float effective_q, float dead_zone_threshold,
|
||||
int width, int height, int decomp_levels, int is_chroma,
|
||||
int (*get_subband_level)(int, int, int, int),
|
||||
int (*get_subband_type)(int, int, int, int)
|
||||
);
|
||||
// Implementation provided by including encoder - just declare prototype
|
||||
|
||||
static void quantise_dwt_coefficients_perceptual_scalar_wrapper(
|
||||
float *coeffs, int16_t *quantised, int size,
|
||||
float *weights, float base_quantiser
|
||||
);
|
||||
// Implementation provided by including encoder
|
||||
|
||||
static void dwt_2d_extract_column_scalar(
|
||||
const float *tile_data, float *column,
|
||||
int x, int width, int height
|
||||
) {
|
||||
for (int y = 0; y < height; y++) {
|
||||
column[y] = tile_data[y * width + x];
|
||||
}
|
||||
}
|
||||
|
||||
static void dwt_2d_insert_column_scalar(
|
||||
float *tile_data, const float *column,
|
||||
int x, int width, int height
|
||||
) {
|
||||
for (int y = 0; y < height; y++) {
|
||||
tile_data[y * width + x] = column[y];
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SIMD Initialization
|
||||
// =============================================================================
|
||||
|
||||
static void tav_simd_init(void) {
|
||||
// Detect CPU capabilities
|
||||
detected_simd_level = detect_simd_capabilities();
|
||||
|
||||
const char *simd_names[] = {"None", "AVX-512", "AVX2", "SSE4.2"};
|
||||
fprintf(stderr, "[TAV] SIMD level detected: %s\n",
|
||||
simd_names[detected_simd_level]);
|
||||
|
||||
#ifdef __AVX512F__
|
||||
if (detected_simd_level == SIMD_AVX512F) {
|
||||
fprintf(stderr, "[TAV] Using AVX-512 optimizations\n");
|
||||
|
||||
// DWT functions
|
||||
extern void dwt_53_forward_1d_avx512(float *data, int length);
|
||||
extern void dwt_97_forward_1d_avx512(float *data, int length);
|
||||
extern void dwt_haar_forward_1d_avx512(float *data, int length);
|
||||
|
||||
dwt_53_forward_1d_ptr = dwt_53_forward_1d_avx512;
|
||||
dwt_97_forward_1d_ptr = dwt_97_forward_1d_avx512;
|
||||
dwt_haar_forward_1d_ptr = dwt_haar_forward_1d_avx512;
|
||||
|
||||
// Quantization
|
||||
// Note: Need wrapper functions that match the complex signature
|
||||
// For now, using scalar versions
|
||||
extern void dwt_53_forward_1d(float *data, int length);
|
||||
extern void dwt_97_forward_1d(float *data, int length);
|
||||
extern void dwt_haar_forward_1d(float *data, int length);
|
||||
extern void dwt_53_inverse_1d(float *data, int length);
|
||||
extern void dwt_haar_inverse_1d(float *data, int length);
|
||||
|
||||
// Fallback to scalar for inverse (can optimize later)
|
||||
dwt_53_inverse_1d_ptr = dwt_53_inverse_1d;
|
||||
dwt_haar_inverse_1d_ptr = dwt_haar_inverse_1d;
|
||||
|
||||
// Color conversion
|
||||
extern void rgb_to_ycocg_avx512(const uint8_t *rgb, float *y, float *co, float *cg, int width, int height);
|
||||
rgb_to_ycocg_ptr = rgb_to_ycocg_avx512;
|
||||
|
||||
// 2D column operations
|
||||
extern void dwt_2d_extract_column_avx512(const float *tile_data, float *column, int x, int width, int height);
|
||||
extern void dwt_2d_insert_column_avx512(float *tile_data, const float *column, int x, int width, int height);
|
||||
|
||||
dwt_2d_extract_column_ptr = dwt_2d_extract_column_avx512;
|
||||
dwt_2d_insert_column_ptr = dwt_2d_insert_column_avx512;
|
||||
|
||||
// Quantization uses scalar for now (needs integration work)
|
||||
extern void dwt_53_forward_1d(float *data, int length);
|
||||
extern void dwt_97_forward_1d(float *data, int length);
|
||||
extern void dwt_haar_forward_1d(float *data, int length);
|
||||
extern void dwt_53_inverse_1d(float *data, int length);
|
||||
extern void dwt_haar_inverse_1d(float *data, int length);
|
||||
extern void rgb_to_ycocg(const uint8_t *rgb, float *y, float *co, float *cg, int width, int height);
|
||||
|
||||
quantise_dwt_coefficients_ptr = quantise_dwt_coefficients_scalar_wrapper;
|
||||
quantise_dwt_coefficients_perceptual_ptr = quantise_dwt_coefficients_perceptual_scalar_wrapper;
|
||||
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Fallback to scalar implementations
|
||||
fprintf(stderr, "[TAV] Using scalar (non-SIMD) implementations\n");
|
||||
|
||||
extern void dwt_53_forward_1d(float *data, int length);
|
||||
extern void dwt_97_forward_1d(float *data, int length);
|
||||
extern void dwt_haar_forward_1d(float *data, int length);
|
||||
extern void dwt_53_inverse_1d(float *data, int length);
|
||||
extern void dwt_haar_inverse_1d(float *data, int length);
|
||||
extern void rgb_to_ycocg(const uint8_t *rgb, float *y, float *co, float *cg, int width, int height);
|
||||
|
||||
dwt_53_forward_1d_ptr = dwt_53_forward_1d;
|
||||
dwt_97_forward_1d_ptr = dwt_97_forward_1d;
|
||||
dwt_haar_forward_1d_ptr = dwt_haar_forward_1d;
|
||||
dwt_53_inverse_1d_ptr = dwt_53_inverse_1d;
|
||||
dwt_haar_inverse_1d_ptr = dwt_haar_inverse_1d;
|
||||
|
||||
rgb_to_ycocg_ptr = rgb_to_ycocg;
|
||||
|
||||
dwt_2d_extract_column_ptr = dwt_2d_extract_column_scalar;
|
||||
dwt_2d_insert_column_ptr = dwt_2d_insert_column_scalar;
|
||||
|
||||
quantise_dwt_coefficients_ptr = quantise_dwt_coefficients_scalar_wrapper;
|
||||
quantise_dwt_coefficients_perceptual_ptr = quantise_dwt_coefficients_perceptual_scalar_wrapper;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Convenience Macros for Code Readability
|
||||
// =============================================================================
|
||||
|
||||
// Use these macros in encoder code for cleaner dispatch
|
||||
#define DWT_53_FORWARD_1D(data, length) \
|
||||
dwt_53_forward_1d_ptr((data), (length))
|
||||
|
||||
#define DWT_97_FORWARD_1D(data, length) \
|
||||
dwt_97_forward_1d_ptr((data), (length))
|
||||
|
||||
#define DWT_HAAR_FORWARD_1D(data, length) \
|
||||
dwt_haar_forward_1d_ptr((data), (length))
|
||||
|
||||
#define RGB_TO_YCOCG(rgb, y, co, cg, width, height) \
|
||||
rgb_to_ycocg_ptr((rgb), (y), (co), (cg), (width), (height))
|
||||
|
||||
#define DWT_2D_EXTRACT_COLUMN(tile_data, column, x, width, height) \
|
||||
dwt_2d_extract_column_ptr((tile_data), (column), (x), (width), (height))
|
||||
|
||||
#define DWT_2D_INSERT_COLUMN(tile_data, column, x, width, height) \
|
||||
dwt_2d_insert_column_ptr((tile_data), (column), (x), (width), (height))
|
||||
|
||||
#endif // TAV_SIMD_DISPATCH_H
|
||||
@@ -1,78 +0,0 @@
|
||||
// Created by CuriousTorvald and Claude on 2025-12-02.
|
||||
// TAV Video Decoder Library - Shared decoding functions for TAV format
|
||||
// Can be used by both regular TAV decoder and TAV-DT decoder
|
||||
|
||||
#ifndef TAV_VIDEO_DECODER_H
|
||||
#define TAV_VIDEO_DECODER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// Video decoder context - opaque to users
|
||||
typedef struct tav_video_context tav_video_context_t;
|
||||
|
||||
// Video parameters structure
|
||||
typedef struct {
|
||||
int width;
|
||||
int height;
|
||||
int decomp_levels; // Spatial DWT levels (typically 4)
|
||||
int temporal_levels; // Temporal DWT levels (typically 2)
|
||||
int wavelet_filter; // 0=CDF 5/3, 1=CDF 9/7, 2=CDF 13/7, 16=DD-4, 255=Haar
|
||||
int temporal_wavelet; // Temporal wavelet (0=CDF 5/3, 1=CDF 9/7)
|
||||
int entropy_coder; // 0=Twobitmap, 1=EZBC, 2=RAW
|
||||
int channel_layout; // 0=YCoCg-R, 1=ICtCp
|
||||
int perceptual_tuning; // 1=perceptual quantisation, 0=uniform
|
||||
uint8_t quantiser_y; // Base quantiser index for Y/I
|
||||
uint8_t quantiser_co; // Base quantiser index for Co/Ct
|
||||
uint8_t quantiser_cg; // Base quantiser index for Cg/Cp
|
||||
uint8_t encoder_preset; // Encoder preset flags (sports, anime, etc.)
|
||||
int monoblock; // 1=single tile (monoblock), 0=multi-tile
|
||||
int no_zstd; // 1=packets are uncompressed (Video Flags bit 4), 0=Zstd compressed
|
||||
} tav_video_params_t;
|
||||
|
||||
// Create video decoder context
|
||||
// Returns NULL on failure
|
||||
tav_video_context_t *tav_video_create(const tav_video_params_t *params);
|
||||
|
||||
// Free video decoder context
|
||||
void tav_video_free(tav_video_context_t *ctx);
|
||||
|
||||
// Decode GOP_UNIFIED packet (0x12) to RGB24 frames
|
||||
// Input: compressed_data - GOP packet data (after packet type byte)
|
||||
// compressed_size - size of compressed data
|
||||
// gop_size - number of frames in GOP (read from packet)
|
||||
// Output: rgb_frames - array of pointers to RGB24 frame buffers (width*height*3 each)
|
||||
// Must be pre-allocated by caller (gop_size pointers, each pointing to width*height*3 bytes)
|
||||
// Returns: 0 on success, -1 on error
|
||||
int tav_video_decode_gop(tav_video_context_t *ctx,
|
||||
const uint8_t *compressed_data, uint32_t compressed_size,
|
||||
uint8_t gop_size, uint8_t **rgb_frames);
|
||||
|
||||
// Decode IFRAME packet (0x10) to RGB24 frame
|
||||
// Input: compressed_data - I-frame packet data (after packet type byte)
|
||||
// packet_size - size of packet data
|
||||
// Output: rgb_frame - pointer to RGB24 frame buffer (width*height*3 bytes)
|
||||
// Must be pre-allocated by caller
|
||||
// Returns: 0 on success, -1 on error
|
||||
int tav_video_decode_iframe(tav_video_context_t *ctx,
|
||||
const uint8_t *compressed_data, uint32_t packet_size,
|
||||
uint8_t *rgb_frame);
|
||||
|
||||
// Decode PFRAME packet (0x11) to RGB24 frame (delta from reference)
|
||||
// Input: compressed_data - P-frame packet data (after packet type byte)
|
||||
// packet_size - size of packet data
|
||||
// Output: rgb_frame - pointer to RGB24 frame buffer (width*height*3 bytes)
|
||||
// Must be pre-allocated by caller
|
||||
// Returns: 0 on success, -1 on error
|
||||
// Note: Requires previous frame to be decoded first (stored internally as reference)
|
||||
int tav_video_decode_pframe(tav_video_context_t *ctx,
|
||||
const uint8_t *compressed_data, uint32_t packet_size,
|
||||
uint8_t *rgb_frame);
|
||||
|
||||
// Get last error message
|
||||
const char *tav_video_get_error(tav_video_context_t *ctx);
|
||||
|
||||
// Enable verbose debug output
|
||||
void tav_video_set_verbose(tav_video_context_t *ctx, int verbose);
|
||||
|
||||
#endif // TAV_VIDEO_DECODER_H
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user