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.
@@ -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.fast ? "1×" : "2×"], ["r", "replay"], ["q", "quit"]];
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. Space pauses, f plays at 2x, r
5
- // starts over, the arrows move two seconds, and q quits. Once the call has
6
- // played to the end, j votes to roll the feature out: signed in, the vote counts
7
- // in place; signed out, it signs in first, votes, and the command ends.
8
- import { spawn } from "node:child_process";
9
- import { existsSync } from "node:fs";
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 sound = !options.quiet && process.platform === "darwin" && existsSync(PLAYER);
26
- const ending = await play(sound ? VOICED : SILENT, sound, environment);
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, sound, environment) {
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 fast = false;
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, fast });
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
- hush();
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
- freeze(paused);
128
+ if (paused)
129
+ voice?.pause();
130
+ else
131
+ voice?.resume();
143
132
  }
144
133
  else if (key === "f") {
145
- // The line being said finishes at its own speed; the next plays faster.
146
- fast = !fast;
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 * (fast ? 2 : 1));
164
- if (sound)
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
- playCue(cue.file, fast);
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.2",
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.2",
35
- "@driggsby/sdk": "0.10.2"
34
+ "@driggsby/deploy": "0.10.4",
35
+ "@driggsby/sdk": "0.10.4"
36
36
  }
37
37
  }