@ciphore/radiocli 0.2.4 → 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.
- package/CHANGELOG.md +103 -2
- package/CONTRIBUTING.md +28 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +182 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +130 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +314 -76
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +21 -24
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/components/ReceiverVisualRow.js +39 -0
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +2 -21
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/theme.js +46 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +2 -2
- package/dist/ui/visualizers/receiver-style-registry.js +3 -1
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +36 -12
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { themeAccent, themeContributionColors } from '../theme.js';
|
|
2
|
+
import { buildBoatLake } from './boat-lake.js';
|
|
3
|
+
import { buildSumiOcean } from './sumi-ocean.js';
|
|
2
4
|
import { retroReceiverBuilders } from './retro-receivers.js';
|
|
3
5
|
import { receiverStyleMetadata } from './receiver-style-registry.js';
|
|
4
6
|
const receiverStyleBuilders = {
|
|
5
7
|
...retroReceiverBuilders,
|
|
8
|
+
'boat-lake': buildBoatLake,
|
|
6
9
|
ultracode: buildUltracode,
|
|
7
10
|
'motion-contour': buildMotionContour,
|
|
8
11
|
leds: buildLeds,
|
|
@@ -97,7 +100,9 @@ const microNativeScaleStyles = new Set([
|
|
|
97
100
|
'galaxy',
|
|
98
101
|
'cyclone',
|
|
99
102
|
'fire',
|
|
100
|
-
'stormfront'
|
|
103
|
+
'stormfront',
|
|
104
|
+
'boat-lake',
|
|
105
|
+
'sumi-ocean'
|
|
101
106
|
]);
|
|
102
107
|
function resampleVisualizer(rows, sourceWidth, sourceHeight, targetWidth, targetHeight, fallbackColor) {
|
|
103
108
|
const source = Array.from({ length: sourceHeight }, (_, rowIndex) => visualCellsForLine(rows[rowIndex], sourceWidth, fallbackColor));
|
|
@@ -4078,12 +4083,6 @@ function buildPlanetarium(pulse, width, height, theme) {
|
|
|
4078
4083
|
}
|
|
4079
4084
|
return grid.map(row => lineFromCells(row, accent));
|
|
4080
4085
|
}
|
|
4081
|
-
function receiverArtPalette(theme, colors) {
|
|
4082
|
-
if (theme !== 'mono') {
|
|
4083
|
-
return colors;
|
|
4084
|
-
}
|
|
4085
|
-
return ['#181818', '#303030', '#505050', '#777777', '#9a9a9a', '#bcbcbc', '#dddddd', '#ffffff'];
|
|
4086
|
-
}
|
|
4087
4086
|
function receiverBandEnergy(pulse, startBand, samples = 6) {
|
|
4088
4087
|
let total = 0;
|
|
4089
4088
|
for (let index = 0; index < samples; index += 1) {
|
|
@@ -4091,107 +4090,6 @@ function receiverBandEnergy(pulse, startBand, samples = 6) {
|
|
|
4091
4090
|
}
|
|
4092
4091
|
return total / Math.max(1, samples);
|
|
4093
4092
|
}
|
|
4094
|
-
/** A deep-perspective indigo ocean rendered with restrained sumi marks. */
|
|
4095
|
-
function buildSumiOcean(pulse, width, height, theme) {
|
|
4096
|
-
const accent = themeAccent(theme);
|
|
4097
|
-
const palette = receiverArtPalette(theme, ['#020817', '#06172c', '#092743', '#0d3c60', '#15567d', '#2777a1', '#66a9cc', '#d7edf5']);
|
|
4098
|
-
const grid = emptyMotionGrid(width, height, palette[0] ?? '#020817');
|
|
4099
|
-
const moonX = width * (0.73 + Math.sin(pulse * 0.006) * 0.015);
|
|
4100
|
-
const moonY = height * 0.22;
|
|
4101
|
-
const moonRadius = Math.max(1.1, height * 0.11);
|
|
4102
|
-
for (let y = 0; y < height; y += 1) {
|
|
4103
|
-
for (let x = 0; x < width; x += 1) {
|
|
4104
|
-
const dx = (x - moonX) / 1.9;
|
|
4105
|
-
const dy = y - moonY;
|
|
4106
|
-
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
4107
|
-
if (distance <= moonRadius) {
|
|
4108
|
-
grid[y][x] = { text: distance < moonRadius * 0.72 ? '█' : '▓', color: palette[7] ?? accent, bold: distance < moonRadius * 0.55 };
|
|
4109
|
-
}
|
|
4110
|
-
else if (hashNoise(x, y, 0) > 0.992) {
|
|
4111
|
-
grid[y][x] = { text: '·', color: palette[5] ?? accent };
|
|
4112
|
-
}
|
|
4113
|
-
}
|
|
4114
|
-
}
|
|
4115
|
-
const horizon = 0.4;
|
|
4116
|
-
const layers = [
|
|
4117
|
-
{ base: 0.42, amp: 0.035, cycles: 15, speed: 0.009, color: 2, glyph: '·' },
|
|
4118
|
-
{ base: 0.47, amp: 0.038, cycles: 12, speed: 0.012, color: 2, glyph: '░' },
|
|
4119
|
-
{ base: 0.53, amp: 0.042, cycles: 10, speed: 0.016, color: 3, glyph: '░' },
|
|
4120
|
-
{ base: 0.61, amp: 0.05, cycles: 8, speed: 0.021, color: 3, glyph: '▒' },
|
|
4121
|
-
{ base: 0.71, amp: 0.06, cycles: 6.5, speed: 0.027, color: 4, glyph: '▒' },
|
|
4122
|
-
{ base: 0.83, amp: 0.075, cycles: 5.2, speed: 0.034, color: 5, glyph: '▓' },
|
|
4123
|
-
{ base: 0.96, amp: 0.1, cycles: 4.2, speed: 0.042, color: 5, glyph: '█' }
|
|
4124
|
-
];
|
|
4125
|
-
const bass = receiverBandEnergy(pulse, 2);
|
|
4126
|
-
// The water plane grows darker and more textured toward the viewer. Far
|
|
4127
|
-
// water is quiet and compressed at the horizon; nearby water occupies more
|
|
4128
|
-
// screen space, producing a clear vanishing-depth read.
|
|
4129
|
-
const horizonRow = Math.max(0, Math.round(height * horizon));
|
|
4130
|
-
for (let y = horizonRow; y < height; y += 1) {
|
|
4131
|
-
const depth = (y - horizonRow) / Math.max(1, height - 1 - horizonRow);
|
|
4132
|
-
const colorIndex = Math.min(5, 2 + Math.floor(depth * 4));
|
|
4133
|
-
for (let x = 0; x < width; x += 1) {
|
|
4134
|
-
const texture = hashNoise(x, y, 41);
|
|
4135
|
-
const glyph = texture > 0.975
|
|
4136
|
-
? ' '
|
|
4137
|
-
: depth > 0.68
|
|
4138
|
-
? texture > 0.48 ? '▓' : '▒'
|
|
4139
|
-
: depth > 0.32
|
|
4140
|
-
? texture > 0.62 ? '▒' : '░'
|
|
4141
|
-
: texture > 0.76 ? '░' : '·';
|
|
4142
|
-
grid[y][x] = { text: glyph, color: palette[colorIndex] ?? accent };
|
|
4143
|
-
}
|
|
4144
|
-
}
|
|
4145
|
-
layers.forEach((layer, layerIndex) => {
|
|
4146
|
-
for (let x = 0; x < width; x += 1) {
|
|
4147
|
-
const nx = x / Math.max(1, width - 1);
|
|
4148
|
-
const phase = nx * layer.cycles * Math.PI * 2 + pulse * layer.speed + layerIndex * 1.9;
|
|
4149
|
-
const ridgeNoise = clampNumber(0.5 +
|
|
4150
|
-
Math.sin(phase) * 0.27 +
|
|
4151
|
-
Math.sin(phase * 1.83 + 0.9) * 0.16 +
|
|
4152
|
-
Math.cos(phase * 3.2 - 0.4) * 0.07, 0, 1);
|
|
4153
|
-
const near = layerIndex / Math.max(1, layers.length - 1);
|
|
4154
|
-
const ridgeY = Math.round(height * (layer.base + layer.amp * (1 - ridgeNoise * 2) - (near > 0.7 ? bass * 0.018 : 0)));
|
|
4155
|
-
const crest = layerIndex < 3 ? '─' : '━';
|
|
4156
|
-
paintCell(grid, x, ridgeY, crest, palette[Math.min(6, layer.color + 1)] ?? accent);
|
|
4157
|
-
if (ridgeY + 1 < height && layerIndex > 1) {
|
|
4158
|
-
paintCell(grid, x, ridgeY + 1, layer.glyph, palette[layer.color] ?? accent);
|
|
4159
|
-
}
|
|
4160
|
-
if (layerIndex > 3 && ridgeY > 0 && (x + layerIndex * 3) % 13 < 3 + Math.floor(near * 3)) {
|
|
4161
|
-
paintCell(grid, x, ridgeY - 1, layerIndex > 5 ? '▀' : '·', palette[6] ?? accent);
|
|
4162
|
-
}
|
|
4163
|
-
}
|
|
4164
|
-
});
|
|
4165
|
-
// A narrow moon path widens toward the foreground, reinforcing the same
|
|
4166
|
-
// perspective even in short terminal viewports.
|
|
4167
|
-
for (let y = horizonRow; y < height; y += 1) {
|
|
4168
|
-
const depth = (y - horizonRow) / Math.max(1, height - 1 - horizonRow);
|
|
4169
|
-
const reflectionHalfWidth = 1 + Math.floor(depth * Math.max(2, width * 0.045));
|
|
4170
|
-
const center = Math.round(moonX + Math.sin(y * 2.1 + pulse * 0.025) * reflectionHalfWidth * 0.45);
|
|
4171
|
-
for (let offset = -reflectionHalfWidth; offset <= reflectionHalfWidth; offset += 1) {
|
|
4172
|
-
if (hashNoise(offset + 30, y, Math.floor(pulse / 12)) > 0.48 + depth * 0.14) {
|
|
4173
|
-
paintCell(grid, center + offset, y, depth > 0.65 ? '━' : '─', palette[6] ?? accent);
|
|
4174
|
-
}
|
|
4175
|
-
}
|
|
4176
|
-
}
|
|
4177
|
-
// Two distant seabirds keep the horizon alive without making it busy.
|
|
4178
|
-
const birdX = Math.round(width * (0.24 + 0.04 * Math.sin(pulse * 0.01)));
|
|
4179
|
-
const birdY = Math.max(1, Math.round(height * 0.2));
|
|
4180
|
-
paintCell(grid, birdX - 1, birdY, '⌁', palette[6] ?? accent);
|
|
4181
|
-
paintCell(grid, birdX + 2, birdY + 1, '⌁', palette[5] ?? accent);
|
|
4182
|
-
// A blue water plane beneath the block texture removes isolated black gaps
|
|
4183
|
-
// while retaining the foreground-to-horizon depth bands.
|
|
4184
|
-
for (let y = horizonRow; y < height; y += 1) {
|
|
4185
|
-
const depth = (y - horizonRow) / Math.max(1, height - 1 - horizonRow);
|
|
4186
|
-
const backgroundIndex = depth > 0.68 ? 3 : depth > 0.28 ? 2 : 1;
|
|
4187
|
-
const backgroundColor = palette[backgroundIndex] ?? palette[1] ?? accent;
|
|
4188
|
-
for (let x = 0; x < width; x += 1) {
|
|
4189
|
-
const cell = grid[y][x];
|
|
4190
|
-
grid[y][x] = { ...cell, backgroundColor };
|
|
4191
|
-
}
|
|
4192
|
-
}
|
|
4193
|
-
return grid.map(row => lineFromCells(row, accent));
|
|
4194
|
-
}
|
|
4195
4093
|
function rgbColor(red, green, blue) {
|
|
4196
4094
|
const channel = (value) => Math.round(clampNumber(value, 0, 255)).toString(16).padStart(2, '0');
|
|
4197
4095
|
return `#${channel(red)}${channel(green)}${channel(blue)}`;
|
|
@@ -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
|
+
}
|
package/dist/update-check.js
CHANGED
|
@@ -96,9 +96,10 @@ export function installUpdate(command = updateCommandForInstall().command) {
|
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
98
|
export function updateShellForPlatform(platform, command) {
|
|
99
|
+
// Login profiles can replace PATH and select a different package-manager installation.
|
|
99
100
|
return platform === 'win32'
|
|
100
101
|
? { command: 'cmd.exe', args: ['/d', '/s', '/c', command] }
|
|
101
|
-
: { command: 'sh', args: ['-
|
|
102
|
+
: { command: 'sh', args: ['-c', command] };
|
|
102
103
|
}
|
|
103
104
|
export function compareSemver(left, right) {
|
|
104
105
|
const leftParts = semverParts(left);
|
|
@@ -34,7 +34,9 @@ Earth vector data is public domain.
|
|
|
34
34
|
|
|
35
35
|
## node-airtunes2
|
|
36
36
|
|
|
37
|
-
AirPlay support
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
AirPlay support uses a source copy of `node-airtunes2` 2.5.0, distributed under
|
|
38
|
+
the GNU Affero General Public License v3, with RadioCLI's native Ed25519 security
|
|
39
|
+
fix. The original attribution, license, complete native source, changes and
|
|
40
|
+
archive provenance ship in `vendor/node-airtunes2/`. Runtime dependencies remain
|
|
41
|
+
optional. See `vendor/node-airtunes2/RADIOCLI_PATCH.md` and the original project
|
|
42
|
+
at <https://github.com/bertrandda/node-airtunes2>.
|
|
@@ -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.
|