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.
- package/LICENSE +21 -0
- package/README.md +920 -0
- package/README.zh.md +790 -0
- package/assets/rain.ogg +0 -0
- package/bin/dsh-glyph-probe.js +51 -0
- package/bin/dsh-live-trace.js +29 -0
- package/bin/dsh-live-working.js +14 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +12 -0
- package/index.js +328 -0
- package/lib/client.js +178 -0
- package/lib/instance.js +68 -0
- package/lib/normalize.js +850 -0
- package/lib/paths.js +66 -0
- package/lib/protocol.js +115 -0
- package/lib/registry.js +232 -0
- package/lib/tools.js +257 -0
- package/lib/tracker.js +648 -0
- package/lib/transport.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -0
- package/picture/call1.png +0 -0
- package/picture/call2.png +0 -0
- package/picture/sleep1.png +0 -0
- package/picture/sleep2.png +0 -0
- package/picture/tui1.png +0 -0
- package/picture/tui2.png +0 -0
- package/picture/type1.png +0 -0
- package/picture/type2.png +0 -0
- package/scripts/bench-render.mjs +69 -0
- package/scripts/demo-working.mjs +130 -0
- package/scripts/demo.mjs +284 -0
- package/scripts/install-profile.mjs +174 -0
- package/scripts/mock-provider.mjs +211 -0
- package/src/cli/cellsize.js +120 -0
- package/src/cli/format.js +73 -0
- package/src/cli/highlight.js +932 -0
- package/src/cli/i18n.js +457 -0
- package/src/cli/main.js +630 -0
- package/src/cli/markdown.js +753 -0
- package/src/cli/renderer.js +1044 -0
- package/src/cli/screen.js +270 -0
- package/src/cli/theme.js +221 -0
- package/src/cli/view-state.js +396 -0
- package/src/cli/views.js +406 -0
- package/src/cli/width.js +337 -0
- package/src/cli/working/art.js +413 -0
- package/src/cli/working/main.js +569 -0
- package/src/cli/working/packing.js +159 -0
- package/src/cli/working/picker.js +75 -0
- package/src/cli/working/props.js +385 -0
- package/src/cli/working/scene.js +837 -0
- package/src/cli/working/sky.js +641 -0
- package/src/cli/working/sound.js +400 -0
- 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
|
+
}
|