dsh-live-trace 0.1.0

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -0
  3. package/README.zh.md +790 -0
  4. package/assets/rain.ogg +0 -0
  5. package/bin/dsh-glyph-probe.js +51 -0
  6. package/bin/dsh-live-trace.js +29 -0
  7. package/bin/dsh-live-working.js +14 -0
  8. package/cordis.patch.yml +31 -0
  9. package/icon.svg +12 -0
  10. package/index.js +328 -0
  11. package/lib/client.js +178 -0
  12. package/lib/instance.js +68 -0
  13. package/lib/normalize.js +850 -0
  14. package/lib/paths.js +66 -0
  15. package/lib/protocol.js +115 -0
  16. package/lib/registry.js +232 -0
  17. package/lib/tools.js +257 -0
  18. package/lib/tracker.js +648 -0
  19. package/lib/transport.js +231 -0
  20. package/locale/en.json +6 -0
  21. package/locale/zh.json +6 -0
  22. package/package.json +94 -0
  23. package/picture/call1.png +0 -0
  24. package/picture/call2.png +0 -0
  25. package/picture/sleep1.png +0 -0
  26. package/picture/sleep2.png +0 -0
  27. package/picture/tui1.png +0 -0
  28. package/picture/tui2.png +0 -0
  29. package/picture/type1.png +0 -0
  30. package/picture/type2.png +0 -0
  31. package/scripts/bench-render.mjs +69 -0
  32. package/scripts/demo-working.mjs +130 -0
  33. package/scripts/demo.mjs +284 -0
  34. package/scripts/install-profile.mjs +174 -0
  35. package/scripts/mock-provider.mjs +211 -0
  36. package/src/cli/cellsize.js +120 -0
  37. package/src/cli/format.js +73 -0
  38. package/src/cli/highlight.js +932 -0
  39. package/src/cli/i18n.js +457 -0
  40. package/src/cli/main.js +630 -0
  41. package/src/cli/markdown.js +753 -0
  42. package/src/cli/renderer.js +1044 -0
  43. package/src/cli/screen.js +270 -0
  44. package/src/cli/theme.js +221 -0
  45. package/src/cli/view-state.js +396 -0
  46. package/src/cli/views.js +406 -0
  47. package/src/cli/width.js +337 -0
  48. package/src/cli/working/art.js +413 -0
  49. package/src/cli/working/main.js +569 -0
  50. package/src/cli/working/packing.js +159 -0
  51. package/src/cli/working/picker.js +75 -0
  52. package/src/cli/working/props.js +385 -0
  53. package/src/cli/working/scene.js +837 -0
  54. package/src/cli/working/sky.js +641 -0
  55. package/src/cli/working/sound.js +400 -0
  56. package/src/cli/working/state.js +528 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Rain, out loud.
3
+ *
4
+ * A terminal cannot make a sound of its own, so this streams synthesised noise
5
+ * to whichever system audio player is installed. It is **off unless asked for**
6
+ * — a command that starts playing audio on its own is a command people stop
7
+ * running — and it only plays while it is actually raining.
8
+ *
9
+ * The noise is generated, not sampled: a low-passed run of pseudo-random
10
+ * samples reads as rain, needs no asset, and can run forever without a loop
11
+ * point.
12
+ *
13
+ * @module dsh-live-working/sound
14
+ */
15
+
16
+ import { delimiter, dirname, join } from 'node:path'
17
+ import { accessSync, constants, existsSync } from 'node:fs'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { spawn as spawnProcess } from 'node:child_process'
20
+
21
+ /** Sample rate of the generated stream. */
22
+ export const SAMPLE_RATE = 22_050
23
+
24
+ /**
25
+ * How loud the rain is out of the box.
26
+ *
27
+ * The recording that ships with the package peaks at -6.1 dBFS, which is a
28
+ * foreground level; at 40% it peaks around -14 dBFS and reads as ambience
29
+ * instead of something to switch off. The level is a fraction, not a
30
+ * percentage, so it is what the player arguments are computed from.
31
+ */
32
+ export const DEFAULT_VOLUME = 0.4
33
+
34
+ /** How much one press of the volume keys moves the level. */
35
+ export const VOLUME_STEP = 0.05
36
+
37
+ /** Full-scale gain of the synthesised noise before the volume is applied. */
38
+ export const NOISE_GAIN = 0.18
39
+
40
+ /** Seed for the synthesised noise, so two runs sound the same. */
41
+ export const NOISE_SEED = 0x2f6e2b1
42
+
43
+ /**
44
+ * Clamp anything to a usable level.
45
+ *
46
+ * A level is a fraction in [0, 1]; a value that is not a number at all falls
47
+ * back to the default rather than to silence or to full scale.
48
+ *
49
+ * @param {unknown} value
50
+ * @returns {number}
51
+ */
52
+ export function clampVolume(value) {
53
+ const number = typeof value === 'number' ? value : Number(value)
54
+ if (!Number.isFinite(number)) return DEFAULT_VOLUME
55
+ return Math.min(1, Math.max(0, number))
56
+ }
57
+
58
+ /** `paplay --volume` is a linear scale over 0-65536. */
59
+ function paplayVolume(volume) {
60
+ return Math.round(clampVolume(volume) * 65_536)
61
+ }
62
+
63
+ /**
64
+ * Players that can take raw PCM on stdin, best first.
65
+ *
66
+ * PulseAudio's `paplay` and ALSA's `aplay` are the common ones on a Linux
67
+ * desktop; `sox` and `ffplay` are the fallbacks for a machine that has neither.
68
+ *
69
+ * `volume` says whether the player can change the level of a *recording* it is
70
+ * given. `paplay`, `sox` and `ffplay` each have an argument for it; `aplay` has
71
+ * none at all, so a recording played through `aplay` is heard at whatever level
72
+ * it was recorded at. The synthesised stream does not depend on this flag: its
73
+ * samples are scaled before they are written, so every player honours the level
74
+ * on that path.
75
+ */
76
+ export const PLAYERS = [
77
+ {
78
+ command: 'aplay',
79
+ // aplay has no volume argument, so a file cannot be attenuated.
80
+ volume: false,
81
+ args: ['-q', '-t', 'raw', '-f', 'S16_LE', '-r', String(SAMPLE_RATE), '-c', '1', '-'],
82
+ fileArgs: (file) => ['-q', file],
83
+ loops: false
84
+ },
85
+ {
86
+ command: 'paplay',
87
+ volume: true,
88
+ args: ['--raw', `--format=s16le`, `--rate=${SAMPLE_RATE}`, '--channels=1'],
89
+ fileArgs: (file, volume = 1) => [`--volume=${paplayVolume(volume)}`, file],
90
+ loops: false
91
+ },
92
+ {
93
+ command: 'sox',
94
+ volume: true,
95
+ args: ['-q', '-t', 'raw', '-e', 'signed', '-b', '16', '-r', String(SAMPLE_RATE), '-c', '1', '-', '-d'],
96
+ fileArgs: (file, volume = 1) => ['-q', '-v', clampVolume(volume).toFixed(2), file, '-d'],
97
+ loops: false
98
+ },
99
+ {
100
+ command: 'ffplay',
101
+ volume: true,
102
+ args: ['-nodisp', '-autoexit', '-loglevel', 'quiet', '-f', 's16le', '-ar', String(SAMPLE_RATE), '-ac', '1', '-i', '-'],
103
+ // The one player that can loop a file by itself.
104
+ fileArgs: (file, volume = 1) => [
105
+ '-nodisp',
106
+ '-loglevel',
107
+ 'quiet',
108
+ '-volume',
109
+ String(Math.round(clampVolume(volume) * 100)),
110
+ '-loop',
111
+ '0',
112
+ file
113
+ ],
114
+ loops: true
115
+ }
116
+ ]
117
+
118
+ /**
119
+ * The first player that is actually installed.
120
+ *
121
+ * @param {Record<string, string | undefined>} [env]
122
+ * @param {(path: string) => boolean} [isExecutable]
123
+ * @returns {{ command: string, args: string[] } | null}
124
+ */
125
+ export function findPlayer(env = process.env, isExecutable = defaultIsExecutable) {
126
+ const directories = String(env?.PATH ?? '')
127
+ .split(delimiter)
128
+ .filter((entry) => entry.length > 0)
129
+ for (const candidate of PLAYERS) {
130
+ for (const directory of directories) {
131
+ if (isExecutable(join(directory, candidate.command))) return candidate
132
+ }
133
+ }
134
+ return null
135
+ }
136
+
137
+ function defaultIsExecutable(path) {
138
+ try {
139
+ accessSync(path, constants.X_OK)
140
+ return true
141
+ } catch {
142
+ return false
143
+ }
144
+ }
145
+
146
+ /**
147
+ * The rain recording that ships with the package, if it is there.
148
+ *
149
+ * @returns {string | null}
150
+ */
151
+ export function bundledRainFile() {
152
+ const path = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'assets', 'rain.ogg')
153
+ return existsSync(path) ? path : null
154
+ }
155
+
156
+ /**
157
+ * A source of rain-like noise.
158
+ *
159
+ * White noise filtered through a one-pole low pass: the top end is rolled off,
160
+ * which is what turns a hiss into rain. The `gain` is what the rain's volume is
161
+ * applied to, so the same generator serves both a quiet and a loud stream.
162
+ *
163
+ * @param {number} [seed]
164
+ * @param {number} [gain] full-scale multiplier, applied before the samples are
165
+ * written; clamped so an over-driven gain cannot overflow an int16
166
+ * @returns {(buffer: Buffer) => Buffer}
167
+ */
168
+ export function createNoiseSource(seed = NOISE_SEED, gain = NOISE_GAIN) {
169
+ let state = seed >>> 0
170
+ let low = 0
171
+ const level = Number.isFinite(gain) ? Math.min(1, Math.max(0, gain)) : NOISE_GAIN
172
+ return function fill(buffer) {
173
+ for (let index = 0; index + 1 < buffer.length; index += 2) {
174
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0
175
+ const white = (state / 0xffffffff) * 2 - 1
176
+ low += (white - low) * 0.35
177
+ const sample = Math.max(-1, Math.min(1, low * 1.6))
178
+ const value = Math.max(-32768, Math.min(32767, Math.round(sample * 32767 * level)))
179
+ buffer.writeInt16LE(value, index)
180
+ }
181
+ return buffer
182
+ }
183
+ }
184
+
185
+ /**
186
+ * A rain loop, played through a system player.
187
+ *
188
+ * By default the noise is synthesised and streamed, which needs no asset at
189
+ * all. Given a recording it plays that instead, looping it — the synthesised
190
+ * version exists so the command works on a machine with nothing but a player,
191
+ * not because it is better than a real recording.
192
+ *
193
+ * @param {{ player?: object | null, spawn?: typeof spawnProcess, chunkBytes?: number, intervalMs?: number, file?: string | null, exists?: (path: string) => boolean, volume?: number }} [options]
194
+ */
195
+ export function createRainSound(options = {}) {
196
+ const player = options.player === undefined ? findPlayer() : options.player
197
+ const spawn = options.spawn ?? spawnProcess
198
+ const chunkBytes = options.chunkBytes ?? 8192
199
+ const intervalMs = options.intervalMs ?? 180
200
+ const exists = options.exists ?? existsSync
201
+ // A file that is not there is not an error: fall back to the synthesised
202
+ // noise rather than going silent.
203
+ // Undefined means "use whatever is bundled"; an explicit null means silence
204
+ // is preferable to a recording.
205
+ const wanted = options.file === undefined ? bundledRainFile() : options.file
206
+ const file = typeof wanted === 'string' && wanted.length > 0 && exists(wanted) ? wanted : null
207
+ const usesFile = file !== null
208
+
209
+ let child = null
210
+ /** Every player process this object has started and not yet reaped. */
211
+ const live = new Set()
212
+ let timer = null
213
+ let enabled = false
214
+ let playing = false
215
+ let restarting = false
216
+ let wetNow = false
217
+ let failures = 0
218
+ let startedAt = 0
219
+ let volume = clampVolume(options.volume === undefined ? DEFAULT_VOLUME : options.volume)
220
+ // The synthesised stream carries its level in its samples, so a change takes
221
+ // effect on the next chunk rather than needing the player restarted.
222
+ let fill = createNoiseSource(NOISE_SEED, NOISE_GAIN * volume)
223
+
224
+ const silence = () => {
225
+ if (timer !== null) clearInterval(timer)
226
+ timer = null
227
+ // Kill everything that was ever started, not just the current reference.
228
+ // Exiting only what `child` points at left orphans behind whenever that
229
+ // reference had been replaced, and they kept playing after the command
230
+ // exited.
231
+ for (const process of live) {
232
+ try {
233
+ process.stdin?.end()
234
+ } catch {
235
+ /* already gone */
236
+ }
237
+ try {
238
+ process.kill()
239
+ } catch {
240
+ /* already gone */
241
+ }
242
+ }
243
+ live.clear()
244
+ child = null
245
+ playing = false
246
+ }
247
+
248
+ const speak = () => {
249
+ if (child === null || child.stdin === null || child.stdin.destroyed) return
250
+ // Skip a chunk rather than queueing one: a backed-up pipe would drift
251
+ // further and further behind the weather.
252
+ if (child.stdin.writableLength > chunkBytes * 4) return
253
+ try {
254
+ child.stdin.write(fill(Buffer.alloc(chunkBytes)))
255
+ } catch {
256
+ silence()
257
+ }
258
+ }
259
+
260
+ const start = () => {
261
+ if (playing || player === null) return
262
+ let started
263
+ try {
264
+ if (usesFile) {
265
+ startedAt = Date.now()
266
+ // The recording's level is the player's own argument; a player that has
267
+ // none ignores the second argument and plays the file as recorded.
268
+ started = spawn(player.command, player.fileArgs(file, volume), { stdio: ['ignore', 'ignore', 'ignore'] })
269
+ } else {
270
+ started = spawn(player.command, player.args, { stdio: ['pipe', 'ignore', 'ignore'] })
271
+ }
272
+ } catch {
273
+ child = null
274
+ return
275
+ }
276
+ child = started
277
+ live.add(started)
278
+ // A process that is no longer the current one must not touch the state.
279
+ // Changing the volume stops the player and starts a new one, and the old
280
+ // one's exit lands *after* the new one is running: without this guard it
281
+ // cleared `child` and `playing`, so the next frame started a third player
282
+ // and the first was never killed.
283
+ started.on('error', () => {
284
+ live.delete(started)
285
+ if (child === started) silence()
286
+ })
287
+ started.on('exit', () => {
288
+ live.delete(started)
289
+ if (child !== started) return
290
+ child = null
291
+ playing = false
292
+ // A player that dies the moment it starts has no audio device, and
293
+ // restarting it forever would be a busy loop. A file that simply ran out
294
+ // lasts its whole length, so it is not counted as a failure.
295
+ const lasted = Date.now() - startedAt
296
+ if (usesFile && lasted < 1000) failures += 1
297
+ if (failures >= 3) return
298
+ // A file that ends has to be started again to keep raining; the players
299
+ // that cannot loop a file themselves need this.
300
+ if (!enabled || restarting || !wetNow) return
301
+ restarting = true
302
+ setTimeout(() => {
303
+ restarting = false
304
+ if (enabled && wetNow) start()
305
+ }, 40).unref?.()
306
+ })
307
+ playing = true
308
+ if (usesFile) return
309
+ speak()
310
+ timer = setInterval(speak, intervalMs)
311
+ // Never keep the process alive: the audio stream must not be the reason a
312
+ // command refuses to exit.
313
+ timer.unref?.()
314
+ }
315
+
316
+ return {
317
+ /** Whether an audio player was found at all. */
318
+ get available() {
319
+ return player !== null
320
+ },
321
+ get player() {
322
+ return player === null ? null : player.command
323
+ },
324
+ /** The recording in use, or null when the noise is synthesised. */
325
+ get file() {
326
+ return file
327
+ },
328
+ get enabled() {
329
+ return enabled
330
+ },
331
+ /** Whether sound is coming out right now. */
332
+ get playing() {
333
+ return playing
334
+ },
335
+ /** The current level, a fraction in [0, 1]. */
336
+ get volume() {
337
+ return volume
338
+ },
339
+ /**
340
+ * Whether the level can actually reach the speakers on the current path.
341
+ *
342
+ * The synthesised stream is scaled by this module, so every player honours
343
+ * it. A recording's level is the player's argument, and `aplay` has none —
344
+ * this is false there rather than silently pretending the file is quieter.
345
+ */
346
+ get volumeHonoured() {
347
+ return player !== null && (!usesFile || player.volume === true)
348
+ },
349
+ setEnabled(value) {
350
+ enabled = value === true
351
+ if (!enabled) silence()
352
+ return enabled
353
+ },
354
+ toggle() {
355
+ return this.setEnabled(!enabled)
356
+ },
357
+ /**
358
+ * Change the rain's level.
359
+ *
360
+ * A recording already being played carries the old level in the player's
361
+ * arguments, so it is restarted; the synthesised stream picks the new gain
362
+ * up on its next chunk without a gap.
363
+ *
364
+ * @param {number} value fraction in [0, 1]
365
+ * @returns {number} the level actually set
366
+ */
367
+ setVolume(value) {
368
+ const next = clampVolume(value)
369
+ if (next === volume) return volume
370
+ volume = next
371
+ fill = createNoiseSource(NOISE_SEED, NOISE_GAIN * volume)
372
+ if (playing && usesFile && player?.volume === true) {
373
+ silence()
374
+ if (enabled && wetNow) start()
375
+ }
376
+ return volume
377
+ },
378
+ /**
379
+ * Move the level by a step, for the volume keys.
380
+ *
381
+ * @param {number} delta
382
+ * @returns {number} the level actually set
383
+ */
384
+ adjustVolume(delta) {
385
+ return this.setVolume(volume + (Number.isFinite(delta) ? delta : 0))
386
+ },
387
+ /**
388
+ * Follow the weather. Called every frame, so it must be cheap when nothing
389
+ * has changed.
390
+ *
391
+ * @param {boolean} wet whether it is raining or storming
392
+ */
393
+ update(wet) {
394
+ wetNow = wet === true
395
+ if (enabled && wetNow) start()
396
+ else if (playing) silence()
397
+ },
398
+ stop: silence
399
+ }
400
+ }