driggsby 0.10.2 → 0.10.4
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/dist/replay/feed-frame.js +7 -1
- package/dist/replay/replay-command.js +32 -43
- package/dist/replay/voice.js +130 -0
- package/package.json +3 -3
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
import { CALL, LINES, OFFER_AT, STEPS } from "./att-story.js";
|
|
7
7
|
import { line, padTo, seg, width } from "./paint.js";
|
|
8
8
|
import { toPlay, toReal } from "./timeline.js";
|
|
9
|
+
// The replay starts at 1.5x; f steps to 2x, then 1x, then back to 1.5x.
|
|
10
|
+
export const SPEEDS = [1.5, 2, 1];
|
|
11
|
+
export function nextSpeed(speed) {
|
|
12
|
+
const at = SPEEDS.findIndex((s) => s === speed);
|
|
13
|
+
return SPEEDS[(at + 1) % SPEEDS.length] ?? SPEEDS[0];
|
|
14
|
+
}
|
|
9
15
|
const C = {
|
|
10
16
|
text: "#EDEDED", soft: "#C9CDD3", mute: "#8B9099", faint: "#5A5F68", ghost: "#30343B", agent: "#7DB4FF",
|
|
11
17
|
music: "#A99BFB", money: "#4ADE80", live: "#F87171", liveDim: "#7A2E2E", keys: "#F5C451", ink: "#0B0C0E",
|
|
@@ -205,7 +211,7 @@ function bottomBar(real, pt, ended, w, narrow, timeline) {
|
|
|
205
211
|
function help(input, w, narrow) {
|
|
206
212
|
const keys = narrow
|
|
207
213
|
? [["space", input.paused ? "play" : "pause"], ["q", "quit"]]
|
|
208
|
-
: [["space", input.paused ? "play" : "pause"], ["f", input.
|
|
214
|
+
: [["space", input.paused ? "play" : "pause"], ["f", `${String(nextSpeed(input.speed))}×`], ["r", "replay"], ["q", "quit"]];
|
|
209
215
|
const left = keys.flatMap(([key, what], i) => [seg((i > 0 ? (narrow ? " " : " · ") : "") + key, C.mute), seg(` ${what}`, C.faint)]);
|
|
210
216
|
// j belongs to the ending: nothing about it shows while the call plays.
|
|
211
217
|
if (input.pt < input.timeline.endStart)
|
|
@@ -1,29 +1,28 @@
|
|
|
1
1
|
// driggsby replay att: a replay of an agent's call with AT&T about an
|
|
2
2
|
// internet bill, in the terminal. On a Mac it plays with sound (recreated
|
|
3
3
|
// voices, the call's real words), paced around them; elsewhere, or with
|
|
4
|
-
// --quiet, it plays silently and faster.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
+
// --quiet, it plays silently and faster. It starts at 1.5x. Space pauses, f
|
|
5
|
+
// steps the speed (2x, 1x, back to 1.5x), r starts over, the arrows move two
|
|
6
|
+
// seconds, and q quits; the voice follows each of them in place. Once the
|
|
7
|
+
// call has played to the end, j votes to roll the feature out: signed in, the
|
|
8
|
+
// vote counts in place; signed out, it signs in first, votes, and the command
|
|
9
|
+
// ends.
|
|
11
10
|
import { CliError } from "../cli-error.js";
|
|
12
11
|
import { defaultCredentialEnvironment, readStoredToken } from "../credentials/store.js";
|
|
13
12
|
import { deployFailure, requireDeploySession } from "../deploy/api-session.js";
|
|
14
13
|
import { runLogin } from "../login/login.js";
|
|
15
14
|
import { CUES, SILENT, VOICED } from "./att-story.js";
|
|
16
|
-
import { MIN_COLS, MIN_ROWS, renderFrame } from "./feed-frame.js";
|
|
15
|
+
import { MIN_COLS, MIN_ROWS, nextSpeed, renderFrame, SPEEDS } from "./feed-frame.js";
|
|
16
|
+
import { lineAt, startVoice } from "./voice.js";
|
|
17
17
|
import { castVote } from "./vote.js";
|
|
18
|
-
const PLAYER = "/usr/bin/afplay";
|
|
19
18
|
const TICK_MS = 33;
|
|
20
19
|
const RETRY_COMMAND = "npx driggsby@latest replay att";
|
|
21
20
|
export async function runReplay(options, environment = defaultCredentialEnvironment()) {
|
|
22
21
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
23
22
|
throw new CliError(`The replay plays in an interactive terminal. Open one and run:\n ${RETRY_COMMAND}`, 1);
|
|
24
23
|
}
|
|
25
|
-
const
|
|
26
|
-
const ending = await play(
|
|
24
|
+
const voice = options.quiet ? null : await startVoice();
|
|
25
|
+
const ending = await play(voice === null ? SILENT : VOICED, voice, environment);
|
|
27
26
|
if (ending === "quit")
|
|
28
27
|
return 0;
|
|
29
28
|
// Signing in needs the terminal: the replay has ended, so sign in, vote, done.
|
|
@@ -47,32 +46,12 @@ async function voteSignedIn(environment) {
|
|
|
47
46
|
return "failed";
|
|
48
47
|
}
|
|
49
48
|
}
|
|
50
|
-
function play(timeline,
|
|
49
|
+
function play(timeline, voice, environment) {
|
|
51
50
|
const stdin = process.stdin;
|
|
52
51
|
const stdout = process.stdout;
|
|
53
|
-
const playing = new Set();
|
|
54
|
-
// SIGKILL, so a line frozen by a pause (SIGSTOP) stops too.
|
|
55
|
-
const hush = () => {
|
|
56
|
-
for (const child of playing)
|
|
57
|
-
child.kill("SIGKILL");
|
|
58
|
-
playing.clear();
|
|
59
|
-
};
|
|
60
|
-
// A pause freezes whatever is being said and a resume picks it up where it
|
|
61
|
-
// stopped, instead of cutting it off until the next line.
|
|
62
|
-
const freeze = (frozen) => {
|
|
63
|
-
for (const child of playing)
|
|
64
|
-
child.kill(frozen ? "SIGSTOP" : "SIGCONT");
|
|
65
|
-
};
|
|
66
|
-
const playCue = (file, fast) => {
|
|
67
|
-
const path = fileURLToPath(new URL(`../../assets/replay/${file}`, import.meta.url));
|
|
68
|
-
const child = spawn(PLAYER, fast ? ["-r", "2", path] : [path], { stdio: "ignore" });
|
|
69
|
-
child.on("error", () => playing.delete(child));
|
|
70
|
-
child.on("exit", () => playing.delete(child));
|
|
71
|
-
playing.add(child);
|
|
72
|
-
};
|
|
73
52
|
let pt = 0;
|
|
74
53
|
let paused = false;
|
|
75
|
-
let
|
|
54
|
+
let speed = SPEEDS[0];
|
|
76
55
|
let vote = "idle";
|
|
77
56
|
let last = Date.now();
|
|
78
57
|
// A full-screen app: the whole terminal, the header at the top and the
|
|
@@ -80,12 +59,19 @@ function play(timeline, sound, environment) {
|
|
|
80
59
|
const draw = () => {
|
|
81
60
|
const cols = Math.max(MIN_COLS, stdout.columns - 1);
|
|
82
61
|
const rows = Math.max(MIN_ROWS, stdout.rows);
|
|
83
|
-
const frame = renderFrame({ pt, nowMs: Date.now(), cols, rows, timeline, vote, paused,
|
|
62
|
+
const frame = renderFrame({ pt, nowMs: Date.now(), cols, rows, timeline, vote, paused, speed });
|
|
84
63
|
stdout.write(`\u001b[H${frame.join("\r\n")}`);
|
|
85
64
|
};
|
|
65
|
+
// A jump picks up whatever line is being said at the new moment, mid-word.
|
|
86
66
|
const seek = (to) => {
|
|
87
|
-
hush();
|
|
88
67
|
pt = Math.max(0, Math.min(timeline.total, to));
|
|
68
|
+
if (voice === null)
|
|
69
|
+
return;
|
|
70
|
+
const line = lineAt(CUES, pt);
|
|
71
|
+
if (line === null)
|
|
72
|
+
voice.stop();
|
|
73
|
+
else
|
|
74
|
+
voice.play(line.file, line.offset, speed, paused);
|
|
89
75
|
};
|
|
90
76
|
return new Promise((resolve) => {
|
|
91
77
|
stdout.write("\u001b[?1049h\u001b[?25l\u001b[2J");
|
|
@@ -97,7 +83,7 @@ function play(timeline, sound, environment) {
|
|
|
97
83
|
return;
|
|
98
84
|
done = true;
|
|
99
85
|
clearInterval(timer);
|
|
100
|
-
|
|
86
|
+
voice?.close();
|
|
101
87
|
stdin.off("data", onKey);
|
|
102
88
|
stdin.setRawMode(false);
|
|
103
89
|
stdin.pause();
|
|
@@ -139,15 +125,18 @@ function play(timeline, sound, environment) {
|
|
|
139
125
|
}
|
|
140
126
|
else if (key === " ") {
|
|
141
127
|
paused = !paused;
|
|
142
|
-
|
|
128
|
+
if (paused)
|
|
129
|
+
voice?.pause();
|
|
130
|
+
else
|
|
131
|
+
voice?.resume();
|
|
143
132
|
}
|
|
144
133
|
else if (key === "f") {
|
|
145
|
-
|
|
146
|
-
|
|
134
|
+
speed = nextSpeed(speed);
|
|
135
|
+
voice?.rate(speed);
|
|
147
136
|
}
|
|
148
137
|
else if (key === "r") {
|
|
149
|
-
seek(0);
|
|
150
138
|
paused = false;
|
|
139
|
+
seek(0);
|
|
151
140
|
}
|
|
152
141
|
else if (key === "\u001b[C")
|
|
153
142
|
seek(pt + 2);
|
|
@@ -160,11 +149,11 @@ function play(timeline, sound, environment) {
|
|
|
160
149
|
const dt = (now - last) / 1000;
|
|
161
150
|
last = now;
|
|
162
151
|
if (!paused && pt < timeline.total) {
|
|
163
|
-
const next = Math.min(timeline.total, pt + dt *
|
|
164
|
-
if (
|
|
152
|
+
const next = Math.min(timeline.total, pt + dt * speed);
|
|
153
|
+
if (voice !== null)
|
|
165
154
|
for (const cue of CUES)
|
|
166
155
|
if (cue.at > pt && cue.at <= next)
|
|
167
|
-
|
|
156
|
+
voice.play(cue.file, 0, speed, false);
|
|
168
157
|
pt = next;
|
|
169
158
|
}
|
|
170
159
|
draw();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The replay's voice on a Mac: one player, macOS's own, driven over a pipe
|
|
2
|
+
// through the built-in osascript. It pauses, resumes, changes speed and
|
|
3
|
+
// stops in place and at once, and only one line ever plays. When the replay
|
|
4
|
+
// ends, or its process dies, the pipe closes and the player stops and exits.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const OSASCRIPT = "/usr/bin/osascript";
|
|
9
|
+
const READY_MS = 3_000;
|
|
10
|
+
// Commands arrive one per line, tab-separated: play <path> <offset> <rate>
|
|
11
|
+
// <paused 0|1>, pause, resume, rate <rate>, stop. A play past the end of its
|
|
12
|
+
// file stays silent, and a resume only continues a line a pause (or a paused
|
|
13
|
+
// jump) held, never one that had already finished. End of input stops the
|
|
14
|
+
// player and exits.
|
|
15
|
+
const PLAYER_SCRIPT = `
|
|
16
|
+
ObjC.import("AVFoundation");
|
|
17
|
+
ObjC.import("Foundation");
|
|
18
|
+
function run() {
|
|
19
|
+
const input = $.NSFileHandle.fileHandleWithStandardInput;
|
|
20
|
+
const output = $.NSFileHandle.fileHandleWithStandardOutput;
|
|
21
|
+
output.writeData($("ready\\n").dataUsingEncoding($.NSUTF8StringEncoding));
|
|
22
|
+
let player = null;
|
|
23
|
+
let held = false;
|
|
24
|
+
let buffer = "";
|
|
25
|
+
for (;;) {
|
|
26
|
+
const data = input.availableData;
|
|
27
|
+
if (Number(data.length) === 0) break;
|
|
28
|
+
buffer += $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js;
|
|
29
|
+
let end;
|
|
30
|
+
while ((end = buffer.indexOf("\\n")) >= 0) {
|
|
31
|
+
const parts = buffer.slice(0, end).split("\\t");
|
|
32
|
+
buffer = buffer.slice(end + 1);
|
|
33
|
+
const command = parts[0];
|
|
34
|
+
if (command === "play") {
|
|
35
|
+
if (player) player.stop;
|
|
36
|
+
player = null;
|
|
37
|
+
held = false;
|
|
38
|
+
const next = $.AVAudioPlayer.alloc.initWithContentsOfURLError($.NSURL.fileURLWithPath(parts[1]), null);
|
|
39
|
+
const offset = Number(parts[2]);
|
|
40
|
+
if (!next.isNil() && offset >= 0 && offset < next.duration) {
|
|
41
|
+
next.enableRate = true;
|
|
42
|
+
next.prepareToPlay;
|
|
43
|
+
next.rate = Number(parts[3]);
|
|
44
|
+
next.currentTime = offset;
|
|
45
|
+
if (parts[4] === "1") held = true;
|
|
46
|
+
else next.play;
|
|
47
|
+
player = next;
|
|
48
|
+
}
|
|
49
|
+
} else if (command === "pause") {
|
|
50
|
+
held = Boolean(player && player.playing);
|
|
51
|
+
if (held) player.pause;
|
|
52
|
+
} else if (command === "resume") {
|
|
53
|
+
if (held) player.play;
|
|
54
|
+
held = false;
|
|
55
|
+
} else if (command === "rate") {
|
|
56
|
+
if (player) player.rate = Number(parts[1]);
|
|
57
|
+
} else if (command === "stop") {
|
|
58
|
+
if (player) player.stop;
|
|
59
|
+
player = null;
|
|
60
|
+
held = false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (player) player.stop;
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
// The line under a moment of the replay: the last sound started at or before
|
|
68
|
+
// it, and how far into it the moment is (past its end, the player stays silent).
|
|
69
|
+
export function lineAt(cues, pt) {
|
|
70
|
+
let found = null;
|
|
71
|
+
for (const cue of cues) {
|
|
72
|
+
if (cue.at > pt)
|
|
73
|
+
break;
|
|
74
|
+
found = cue;
|
|
75
|
+
}
|
|
76
|
+
return found === null ? null : { file: found.file, offset: pt - found.at };
|
|
77
|
+
}
|
|
78
|
+
// One command line for the player; null for a path it couldn't carry.
|
|
79
|
+
export function playerCommand(parts) {
|
|
80
|
+
const fields = parts.map(String);
|
|
81
|
+
return fields.some((field) => /[\t\n\r]/.test(field)) ? null : `${fields.join("\t")}\n`;
|
|
82
|
+
}
|
|
83
|
+
// The player, ready to speak, or null where there is none (not a Mac, or it
|
|
84
|
+
// didn't start): the replay then plays silently.
|
|
85
|
+
export function startVoice() {
|
|
86
|
+
if (process.platform !== "darwin" || !existsSync(OSASCRIPT))
|
|
87
|
+
return Promise.resolve(null);
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const child = spawn(OSASCRIPT, ["-l", "JavaScript", "-e", PLAYER_SCRIPT], { stdio: ["pipe", "pipe", "ignore"] });
|
|
90
|
+
let settled = false;
|
|
91
|
+
const settle = (voice) => {
|
|
92
|
+
if (settled)
|
|
93
|
+
return;
|
|
94
|
+
settled = true;
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
if (voice === null)
|
|
97
|
+
child.kill();
|
|
98
|
+
resolve(voice);
|
|
99
|
+
};
|
|
100
|
+
const timer = setTimeout(() => { settle(null); }, READY_MS);
|
|
101
|
+
child.on("error", () => { settle(null); });
|
|
102
|
+
child.on("exit", () => { settle(null); });
|
|
103
|
+
// The player may be gone before a write lands; a lost line is silence.
|
|
104
|
+
child.stdin.on("error", () => undefined);
|
|
105
|
+
child.stdout.on("data", (chunk) => {
|
|
106
|
+
if (chunk.toString("utf8").includes("ready"))
|
|
107
|
+
settle(voiceOver(child));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function voiceOver(child) {
|
|
112
|
+
const send = (parts) => {
|
|
113
|
+
const line = playerCommand(parts);
|
|
114
|
+
if (line !== null && child.stdin.writable)
|
|
115
|
+
child.stdin.write(line);
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
play: (file, offset, rate, paused) => {
|
|
119
|
+
send(["play", fileURLToPath(new URL(`../../assets/replay/${file}`, import.meta.url)), offset.toFixed(3), rate, paused ? 1 : 0]);
|
|
120
|
+
},
|
|
121
|
+
pause: () => { send(["pause"]); },
|
|
122
|
+
resume: () => { send(["resume"]); },
|
|
123
|
+
rate: (rate) => { send(["rate", rate]); },
|
|
124
|
+
stop: () => { send(["stop"]); },
|
|
125
|
+
close: () => {
|
|
126
|
+
send(["stop"]);
|
|
127
|
+
child.stdin.end();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driggsby",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
4
4
|
"description": "Set up and build on Driggsby, the MCP server for personal finance.",
|
|
5
5
|
"license": "Apache-2.0 AND OFL-1.1",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=18"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@driggsby/deploy": "0.10.
|
|
35
|
-
"@driggsby/sdk": "0.10.
|
|
34
|
+
"@driggsby/deploy": "0.10.4",
|
|
35
|
+
"@driggsby/sdk": "0.10.4"
|
|
36
36
|
}
|
|
37
37
|
}
|