@ciphore/radiocli 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ import { encodeTerminalCell } from './terminal-cell.js';
2
+ // Original artwork inspired by the quiet horizon and moon path at boat.dev.
3
+ const palette = [
4
+ '#05091b', '#080e26', '#0b1432', '#101c40', '#14254e', '#192e60',
5
+ '#1f3973', '#284784', '#335694', '#4165a4', '#5376b4', '#688ac2',
6
+ '#809fd0', '#9ab4dd', '#b8cbe9', '#d6e2f3', '#ecf2fb', '#fcfdff'
7
+ ];
8
+ const mono = palette.map(color => {
9
+ const value = Number.parseInt(color.slice(1), 16);
10
+ const channel = Math.round(((value >> 16) * 0.2126 + ((value >> 8) & 255) * 0.7152 + (value & 255) * 0.0722));
11
+ return `#${channel.toString(16).padStart(2, '0').repeat(3)}`;
12
+ });
13
+ const scenes = new Map();
14
+ const clamp = (value) => Math.max(0, Math.min(1, value));
15
+ const smooth = (value) => { const t = clamp(value); return t * t * (3 - 2 * t); };
16
+ function hash(x, y) {
17
+ let value = Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
18
+ value = Math.imul(value ^ (value >>> 13), 1274126177);
19
+ return ((value ^ (value >>> 16)) >>> 0) / 4294967295;
20
+ }
21
+ function noise(x, row) {
22
+ const left = Math.floor(x), blend = smooth(x - left);
23
+ return hash(left, row) * (1 - blend) + hash(left + 1, row) * blend;
24
+ }
25
+ function sceneFor(width, height) {
26
+ const key = `${width}:${height}`;
27
+ const cached = scenes.get(key);
28
+ if (cached)
29
+ return cached;
30
+ // 2×4 square subpixels match a terminal's 1:2 cell aspect. The previous 2×2
31
+ // grid made the tiny moon rectangular and merged thin crests into blocks.
32
+ const columns = width * 2, rows = height * 4;
33
+ const horizon = rows * (height <= 6 ? 0.43 : 0.46);
34
+ const moonColumn = Math.round(width * 0.78);
35
+ const moonRow = Math.floor(horizon / 4 * 0.40);
36
+ const moonX = (moonColumn + (height <= 8 ? 0.5 : 0)) * 2;
37
+ const moonY = height <= 8 ? moonRow * 4 + 2 : horizon * 0.40;
38
+ const radius = Math.max(height <= 3 ? 1.6 : 2, Math.min(6, height * 0.24));
39
+ const sky = new Float32Array(columns * rows).fill(-1);
40
+ const depths = Float32Array.from({ length: rows }, (_, y) => (y + 0.5 - horizon) / Math.max(1, rows - horizon));
41
+ const firstWater = depths.findIndex(depth => depth >= 0);
42
+ for (let y = 0; y < rows; y++)
43
+ for (let x = 0; x < columns; x++) {
44
+ const index = y * columns + x;
45
+ const u = (x + 0.5) / columns;
46
+ if (y < firstWater) {
47
+ const dx = x + 0.5 - moonX, dy = y + 0.5 - moonY;
48
+ const distance = Math.hypot(dx, dy);
49
+ const halo = Math.exp(-(distance * distance) / (radius * radius * 5));
50
+ const altitude = (y + 0.5) / horizon;
51
+ let shade = 1.1 + altitude * 2.6 + halo * 3.6;
52
+ const cloud = Math.sin(u * 15 + altitude * 4) * Math.sin(altitude * 11 - u * 4);
53
+ shade += Math.max(0, cloud) * smooth((altitude - 0.3) / 0.5) * 0.55;
54
+ const limb = smooth((radius + 0.4 - distance) / 0.8);
55
+ if (limb > 0 && height > 8) {
56
+ const crater = Math.exp(-(((dx + radius * 0.25) ** 2 + (dy - radius * 0.15) ** 2) / (radius * radius * 0.16)));
57
+ shade = shade * (1 - limb) + (17 - crater * 1.3) * limb;
58
+ }
59
+ else if (height >= 7 && y < horizon * 0.65 && distance > radius * 3 && hash(x, y + 17) > 0.997) {
60
+ shade += 2.4;
61
+ }
62
+ sky[index] = shade;
63
+ continue;
64
+ }
65
+ }
66
+ const samples = new Float32Array(8);
67
+ // Only the sky is static. Cache complete sky rows, and sample the boundary
68
+ // again with the water so every ripple across the sea can travel sideways.
69
+ const skyline = Array.from({ length: Math.floor(firstWater / 4) }, (_, y) => {
70
+ const runs = [];
71
+ for (let x = 0; x < width; x++) {
72
+ for (let q = 0; q < 8; q++)
73
+ samples[q] = sky[(y * 4 + (q >> 1)) * columns + x * 2 + (q & 1)];
74
+ const moon = height <= 8 && width >= 8 && y === moonRow && x === moonColumn;
75
+ const cell = moon ? { glyph: '●', fg: 17, bg: 4 } : encodeTerminalCell(samples, x, y, 17);
76
+ const previous = runs.at(-1);
77
+ if (previous?.fg === cell.fg && previous.bg === cell.bg)
78
+ previous.text += cell.glyph;
79
+ else
80
+ runs.push({ text: cell.glyph, fg: cell.fg, bg: cell.bg });
81
+ }
82
+ return runs;
83
+ });
84
+ const scene = { sky, skyline, depths, firstWater, moonColumn, moonRow, moonX };
85
+ if (scenes.size >= 4)
86
+ scenes.delete(scenes.keys().next().value);
87
+ scenes.set(key, scene);
88
+ return scene;
89
+ }
90
+ const quadrants = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█';
91
+ // Broad quadrant marks retain the previous receiver's soft, horizontal water.
92
+ // Average the fine samples instead of fitting sharp diagonal shapes to glints.
93
+ function waterCell(samples, ambient) {
94
+ const values = [(samples[0] + samples[2]) / 2, (samples[1] + samples[3]) / 2,
95
+ (samples[4] + samples[6]) / 2, (samples[5] + samples[7]) / 2];
96
+ const mean = values.reduce((sum, value) => sum + value, 0) / 4;
97
+ const threshold = ambient === undefined ? mean : ambient + 0.2;
98
+ let mask = 0, high = 0, low = 0, count = 0;
99
+ for (let q = 0; q < 4; q++) {
100
+ if (values[q] > threshold) {
101
+ mask |= 1 << q;
102
+ high += values[q];
103
+ count++;
104
+ }
105
+ else
106
+ low += values[q];
107
+ }
108
+ const base = Math.max(1, Math.floor((ambient ?? mean) - 0.35));
109
+ const fg = ambient === undefined ? Math.min(17, Math.round(count ? high / count : mean)) : base + 2;
110
+ const bg = ambient === undefined ? Math.min(17, Math.round(count < 4 ? low / (4 - count) : mean)) : base;
111
+ return { glyph: fg === bg ? ' ' : quadrants[mask], fg, bg };
112
+ }
113
+ /** A steady moon above travelling water and a broad, ripple-broken reflection. */
114
+ export function buildSumiOcean(pulse, width, height, theme) {
115
+ const scene = sceneFor(width, height);
116
+ const columns = width * 2, rows = height * 4;
117
+ const time = pulse * 0.08;
118
+ const colors = theme === 'mono' ? mono : palette;
119
+ const field = new Float32Array(scene.sky.length);
120
+ const ambient = new Float32Array(rows);
121
+ for (let y = scene.firstWater; y < rows; y++) {
122
+ const depth = scene.depths[y];
123
+ // Preserve the old perspective at normal sizes, with fewer distinct wave
124
+ // bands when the viewport is short so the surface cannot alias away.
125
+ const band = Math.floor(depth * Math.min(30, Math.max(6, height * 2)));
126
+ const phase = time * 0.48 + depth * 31;
127
+ const wander = Math.sin(phase * 0.63) * (0.003 + depth * 0.008) * columns;
128
+ const pathWidth = Math.max(1.1, columns * 0.008) + depth * columns * 0.055;
129
+ const base = 5.7 - depth * 2.6 + Math.sin(depth * 24 - time * 0.42) * 0.4;
130
+ ambient[y] = base;
131
+ const catchLight = smooth((noise(time * 0.5 + band * 0.37, band + 51) - 0.3) / 0.48);
132
+ const scale = 68 - depth * 45;
133
+ const drift = time * (0.18 + depth * 0.25);
134
+ for (let x = 0; x < columns; x++) {
135
+ const ripple = noise((x + 0.5) / columns * scale + drift, band + 23);
136
+ let value = base + (ripple - 0.5) * (1 + depth * 1.5);
137
+ const offset = (x + 0.5 - scene.moonX - wander) / pathWidth;
138
+ if (Math.abs(offset) < 3.2) {
139
+ const path = Math.exp(-offset * offset);
140
+ const fragment = smooth((ripple - 0.33) / 0.42);
141
+ value += path * (2.7 + catchLight * fragment * (7.5 - depth * 1.7));
142
+ }
143
+ value += smooth((ripple - 0.72) / 0.2) * (0.8 + depth * 1.6);
144
+ field[y * columns + x] = Math.min(17, value);
145
+ }
146
+ }
147
+ const result = [];
148
+ const samples = new Float32Array(8);
149
+ for (let y = 0; y < height; y++) {
150
+ let text = '';
151
+ const segments = [];
152
+ const append = (value, fg, bg) => {
153
+ const color = colors[fg], backgroundColor = colors[bg];
154
+ text += value;
155
+ const previous = segments.at(-1);
156
+ if (previous?.color === color && previous.backgroundColor === backgroundColor)
157
+ previous.text += value;
158
+ else
159
+ segments.push({ text: value, color, backgroundColor });
160
+ };
161
+ const skyline = scene.skyline[y];
162
+ if (skyline) {
163
+ for (const run of skyline)
164
+ append(run.text, run.fg, run.bg);
165
+ }
166
+ else
167
+ for (let x = 0; x < width; x++) {
168
+ for (let q = 0; q < 8; q++) {
169
+ const sy = y * 4 + (q >> 1), index = sy * columns + x * 2 + (q & 1);
170
+ samples[q] = sy < scene.firstWater ? scene.sky[index] : field[index];
171
+ }
172
+ const depth = (scene.depths[y * 4] + scene.depths[y * 4 + 3]) / 2;
173
+ const farFromReflection = Math.abs((x + 0.5) * 2 - scene.moonX) / columns > 0.018 + depth * 0.13;
174
+ const base = y * 4 >= scene.firstWater && farFromReflection ? (ambient[y * 4] + ambient[y * 4 + 3]) / 2 : undefined;
175
+ let cell = waterCell(samples, base);
176
+ if (height <= 8 && width >= 8 && y === scene.moonRow && x === scene.moonColumn)
177
+ cell = { glyph: '●', fg: 17, bg: 4 };
178
+ append(cell.glyph, cell.fg, cell.bg);
179
+ }
180
+ result.push({ text, color: colors[7], segments });
181
+ }
182
+ return result;
183
+ }
@@ -0,0 +1,45 @@
1
+ const dither = [0, 0.5, 0.125, 0.625, 0.75, 0.25, 0.875, 0.375, 0.1875, 0.6875, 0.0625, 0.5625, 0.9375, 0.4375, 0.8125, 0.3125];
2
+ const glyphs = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█';
3
+ // Eight samples retain diagonals that disappear when collapsed into quadrants.
4
+ // All glyphs are single-column BMP characters supported by existing TUI fonts.
5
+ const patterns = [...glyphs].slice(1, 8).map((glyph, index) => {
6
+ const quadrant = index + 1;
7
+ return { glyph, mask: (quadrant & 1 ? 5 : 0) | (quadrant & 2 ? 10 : 0) | (quadrant & 4 ? 80 : 0) | (quadrant & 8 ? 160 : 0) };
8
+ });
9
+ patterns.push({ glyph: '◢', mask: 232 }, { glyph: '◣', mask: 212 }, { glyph: '◤', mask: 23 }, { glyph: '◥', mask: 43 }, { glyph: '▂', mask: 192 }, { glyph: '▆', mask: 252 });
10
+ const clusters = patterns.map(pattern => ({ ...pattern, indices: Array.from({ length: 8 }, (_, q) => q).filter(q => pattern.mask & (1 << q)) }));
11
+ export function encodeTerminalCell(samples, x, y, maximum) {
12
+ let total = 0, squared = 0;
13
+ for (const sample of samples) {
14
+ total += sample;
15
+ squared += sample * sample;
16
+ }
17
+ const mean = total / 8;
18
+ const base = Math.floor(mean);
19
+ const shade = Math.min(4, Math.floor((mean - base) * 4 + dither[(y & 3) * 4 + (x & 3)]));
20
+ const value = base + shade / 4;
21
+ let error = 0;
22
+ for (const sample of samples)
23
+ error += (sample - value) ** 2;
24
+ error *= 0.8;
25
+ let glyph = [' ', '░', '▒', '▓', ' '][shade];
26
+ let fg = Math.min(maximum, base + 1), bg = Math.min(maximum, base + (shade === 4 ? 1 : 0));
27
+ for (const pattern of clusters) {
28
+ let sum = 0;
29
+ const count = pattern.indices.length;
30
+ for (const q of pattern.indices)
31
+ sum += samples[q];
32
+ const a = Math.round(sum / count), b = Math.round((total - sum) / (8 - count));
33
+ if (a === b)
34
+ continue;
35
+ // Sum of squared residuals, without resampling each cluster's members.
36
+ const cost = squared - 2 * a * sum + count * a * a - 2 * b * (total - sum) + (8 - count) * b * b;
37
+ if (cost < error) {
38
+ error = cost;
39
+ glyph = pattern.glyph;
40
+ fg = a;
41
+ bg = b;
42
+ }
43
+ }
44
+ return { glyph, fg, bg };
45
+ }
@@ -0,0 +1,88 @@
1
+ # Boat Lake receiver
2
+
3
+ `boat-lake` is a local adaptation of the procedural lake behind [boat.dev](https://boat.dev/).
4
+ The original scene was extracted on October 28, 2026 from the public client bundle
5
+ [`26-7j4wbk3v76.js`](https://boat.dev/_next/static/chunks/26-7j4wbk3v76.js).
6
+ The landscape, lighting, water, reflections and sail polygons come from that
7
+ scene. Provenance comments are retained in the isolated kernel and local
8
+ reference. No website copy, branding, fonts, analytics or network code is included.
9
+
10
+ ## Preview locally
11
+
12
+ ```sh
13
+ node scripts/build-boat-lake-preview.mjs
14
+ python3 -m http.server 4178 --bind 127.0.0.1 --directory output/boat-lake
15
+ ```
16
+
17
+ Open <http://127.0.0.1:4178>. Switch between **Extracted scene** and **Receiver style**.
18
+ The reference runs the original layered drawing and character quantizer. The
19
+ receiver runs the terminal adapter. The preview defaults to actual TUI proportions
20
+ with 8×16 pixel cells. **Enlarge** magnifies those cells without inventing detail.
21
+ Sizes range from 120×30 to 20×3, including the normal 120×18 receiver maximum.
22
+ [Size checks](http://127.0.0.1:4178/sizes.html) show several sizes together, plus
23
+ real Unicode text rendered in browser monospace for the smaller layouts.
24
+ Terminal font metrics can differ from the canvas representation.
25
+
26
+ Pause/play is available; the preview also pauses in hidden tabs and starts still
27
+ when reduced motion is requested. Both reference and receiver use fixed daytime
28
+ lighting (phase `0.44`) with no page fade. The builder uses installed esbuild and
29
+ bundled local sources. It does not fetch assets. Output is ignored by Git.
30
+
31
+ Select **Boat Lake** in RadioCLI's existing receiver-style picker. The docs
32
+ receiver gallery uses the same registry and renderer. The radio default and saved
33
+ receiver selection are unchanged.
34
+
35
+ ## Composition and rendering
36
+
37
+ - Size the scene to the viewport rather than squeezing a fixed 160×42 artboard
38
+ into every receiver. Short layouts use a lower horizon, with fewer, larger boats.
39
+ - Keep the foreground boat away from the bright sun reflection for contrast.
40
+ - Rasterize the original sail and hull polygons directly; sampling already
41
+ quantized sprites a second time erased their small features.
42
+ - Preserve eight samples per cell, with quadrant blocks, diagonal triangles,
43
+ quarter-height blocks and fine ordered shading. Evaluate two-color cluster
44
+ errors from their sums rather than repeatedly calculating every residual.
45
+ - At 3–6 rows, use explicit terminal-grid sails, hull and pine silhouettes so they
46
+ remain recognizable. The smallest receivers use one boat.
47
+ - Adapt the upstream pine minimum height so short scenes retain their tips.
48
+ - Cache static scenery, encoded static cells and a detailed reflection field.
49
+ Interpolated horizontal refraction animates the cached water; larger boats bob
50
+ gently. This is an approximation of the original water animation, rather than
51
+ rerunning its full reflected scene on every frame.
52
+ - Retain at most four viewport caches with no playback history. Preserve the
53
+ original blue palette, with grayscale for the mono theme, and merge adjacent
54
+ equal-color cells into terminal runs.
55
+
56
+ The receiver needs no GPU, images, external fonts or network service. Motion uses
57
+ RadioCLI's existing receiver clock and pause behavior.
58
+
59
+ ## Measurements
60
+
61
+ ```sh
62
+ npx tsx scripts/boat-lake/benchmark.mts
63
+ ```
64
+
65
+ The benchmark warms both renderers for 60 frames, then measures 200 frames per
66
+ output resolution. The reference measures original field sampling and original
67
+ character quantization on its fixed artboard. The receiver additionally includes
68
+ boat compositing and segment creation, and uses its adaptive composition and
69
+ cached refraction animation. This compares their steady-state costs, not identical
70
+ per-frame algorithms. A first frame or cache miss also builds the reflected field.
71
+ Neither measurement includes browser painting, Ink rendering or terminal I/O.
72
+
73
+ Node v24.15.0 on this machine:
74
+
75
+ | Viewport | Reference median | Receiver median | Receiver p95 |
76
+ | --- | ---: | ---: | ---: |
77
+ | 120×30 | 9.258 ms | 0.869 ms | 1.115 ms |
78
+ | 120×18 | 5.952 ms | 0.522 ms | 0.751 ms |
79
+ | 100×14 | 3.547 ms | 0.360 ms | 0.445 ms |
80
+ | 80×10 | 2.041 ms | 0.225 ms | 0.318 ms |
81
+ | 60×7 | 1.135 ms | 0.142 ms | 0.209 ms |
82
+ | 36×4 | 0.374 ms | 0.061 ms | 0.092 ms |
83
+ | 24×4 | 0.246 ms | 0.026 ms | 0.027 ms |
84
+ | 20×3 | 0.148 ms | 0.016 ms | 0.017 ms |
85
+
86
+ Tests cover terminal column widths, glyphs and palettes, merged segments,
87
+ monochrome, steady scenery with animated water, detailed shading, recognizable
88
+ compact sails/hulls/pines throughout playback, and determinism after cache eviction.
@@ -0,0 +1,80 @@
1
+ # Sumi Ocean refinement
2
+
3
+ The existing `sumi-ocean` receiver takes visual cues from the moonlit harbour above
4
+ [boat.dev's footer](https://boat.dev/): a clear moon, soft halo, quiet horizon and
5
+ broken silver reflections. Its artwork is original typed TypeScript. It uses no
6
+ harbour code, boats, jetty, barrels, images, external fonts or network services.
7
+
8
+ The style ID, saved selection and indigo palette remain the same, including a
9
+ monochrome version. The sky stays steady. Reflections shimmer and refract through
10
+ RadioCLI's existing receiver clock and pause behavior.
11
+
12
+ ## Local comparison
13
+
14
+ ```sh
15
+ node scripts/build-boat-lake-preview.mjs
16
+ python3 -m http.server 4178 --bind 127.0.0.1 --directory output/boat-lake
17
+ ```
18
+
19
+ Open <http://127.0.0.1:4178/ocean.html>. The **Previous receiver** button shows the
20
+ four-sample renderer immediately before this refinement. Compare sizes from
21
+ 120×30 down to 20×3, in indigo or monochrome. The normal receiver maximum is 14
22
+ rows; 30 rows are an enlarged study. The preview now defaults to actual TUI
23
+ proportions with 8×16 pixel cells. **Enlarge** magnifies those same cells.
24
+
25
+ [Size checks](http://127.0.0.1:4178/ocean-sizes.html) show several sizes together,
26
+ including real Unicode text in browser monospace. Terminal font metrics can
27
+ differ from the canvas representation. The preview pauses in hidden tabs and
28
+ starts still when reduced motion is requested. An optional local footer screenshot
29
+ appears when `output/boat-lake/footer-reference.png` is present. No asset is fetched
30
+ from the original website.
31
+
32
+ ## Small receivers and detail
33
+
34
+ - Use eight samples per cell on a 2×4 square-subpixel grid, matching the terminal's
35
+ 1:2 cell aspect. The sky uses Boat Lake's fine cell encoder and ordered shading.
36
+ - Keep the previous receiver's broad quadrant marks for water. Average the fine
37
+ samples into quadrants instead of fitting diagonal shapes to the reflection.
38
+ - Adapt the horizon, moon size and number of wave bands to the viewport. Short
39
+ receivers avoid undersampling a fixed thirty-band water field.
40
+ - Below nine rows, draw a round native circle glyph so the moon stays legible.
41
+ - Restore the previous receiver's wide, ripple-broken reflection. Its center
42
+ meanders gently, its path widens toward the foreground, and travelling water
43
+ texture determines which fragments catch the moonlight.
44
+
45
+ ## Caching and animation
46
+
47
+ Cache the sky field, encoded sky rows and depth coordinates by viewport. Animate
48
+ water across its full width with the previous receiver's travelling noise and
49
+ perspective-dependent speed. Reflection and water share the same moving texture,
50
+ so their motion stays connected. Do not freeze the sea outside the reflection
51
+ band: that optimization removed the lateral motion that made the previous scene
52
+ work.
53
+
54
+ The cache retains at most four viewports and no playback history. Water fields
55
+ and returned segments are newly created, so consumer mutations cannot damage
56
+ cached data. The renderer adds no dependency, timer or GPU requirement.
57
+
58
+ ## Local measurements
59
+
60
+ ```sh
61
+ npx tsx scripts/boat-lake/benchmark-ocean.mts
62
+ ```
63
+
64
+ The immediately preceding renderer is frozen in
65
+ `scripts/boat-lake/sumi-quadrants.mjs`. Each renderer receives 60 warm-up frames
66
+ and 200 measured frames at matching dimensions. Measurements report scene setup,
67
+ median and p95 CPU time, and average color-run counts. They exclude browser
68
+ painting, Ink rendering and terminal I/O.
69
+
70
+ Node v24.15.0 on this machine measured a 0.114 ms median and 0.145 ms p95
71
+ for the current 100×14 renderer, versus 0.068 ms and 0.100 ms for the previous
72
+ four-sample version. At 36×4, the current median was 0.015 ms versus 0.007 ms.
73
+ Restoring full-surface motion does more work than animating only a narrow strip;
74
+ these local CPU measurements exclude painting and terminal I/O.
75
+
76
+ Tests cover terminal column widths, glyphs, continuous backgrounds, merged runs,
77
+ a visible moon and reflected light throughout long playback at small sizes,
78
+ water texture moving on both sides of the reflection, steady sky, monochrome,
79
+ cache eviction, and mutation isolation. Water uses broad quadrant marks without
80
+ forced bars or diagonal reflection shapes. The terminal snapshot is updated.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -81,7 +81,8 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@homebridge/dbus-native": "^0.7.9",
84
- "@modelcontextprotocol/server": "^2.0.0",
84
+ "@modelcontextprotocol/server": "^2.3.1",
85
+ "chalk": "^5.6.2",
85
86
  "ink": "^7.0.5",
86
87
  "jsonc-parser": "^3.3.1",
87
88
  "react": "^19.2.6",