@audiofool/moondog 0.2.0-beta.6 → 0.2.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 (34) hide show
  1. package/README.md +5 -4
  2. package/assets/brand/moondog-logo.svg +1 -0
  3. package/package.json +2 -2
  4. package/scripts/moondog.mjs +30 -6
  5. package/src/core/application-update.mjs +5 -3
  6. package/src/core/first-run.mjs +53 -0
  7. package/src/i18n/screen/es.mjs +62 -4
  8. package/src/i18n/screen/ja.mjs +62 -4
  9. package/src/i18n/screen/pt.mjs +62 -4
  10. package/src/i18n/screen/zh.mjs +62 -4
  11. package/src/integrations/netease/ncm-cli-player.mjs +29 -13
  12. package/src/memory/reflection-worker.mjs +1 -1
  13. package/src/runtime/pi/authentication.mjs +31 -10
  14. package/src/runtime/pi/configured-runtime.mjs +3 -3
  15. package/src/runtime/pi/custom-endpoints.mjs +229 -0
  16. package/src/runtime/pi/model-catalog.mjs +7 -3
  17. package/src/runtime/pi/model-list.mjs +122 -0
  18. package/src/runtime/pi/model-settings.mjs +131 -20
  19. package/src/runtime/pi/model-transport.mjs +2 -2
  20. package/src/runtime/pi/persistent-credential-store.mjs +31 -0
  21. package/src/runtime/pi/provider-registry.mjs +3 -9
  22. package/src/runtime/pi/public-model-catalog.mjs +12 -4
  23. package/src/runtime/pi/runtime-selection.mjs +9 -0
  24. package/src/runtime/pi/runtime-settings.mjs +37 -0
  25. package/src/surfaces/brand/logo.mjs +237 -0
  26. package/src/surfaces/cli/auth-command.mjs +25 -56
  27. package/src/surfaces/cli/brand-components.mjs +1 -1
  28. package/src/surfaces/cli/command-completions.mjs +14 -0
  29. package/src/surfaces/cli/endpoint-command.mjs +106 -0
  30. package/src/surfaces/cli/first-run-view.mjs +138 -0
  31. package/src/surfaces/cli/format-output.mjs +1 -1
  32. package/src/surfaces/cli/terminal-art.mjs +24 -66
  33. package/src/surfaces/cli/tui.mjs +320 -33
  34. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
@@ -0,0 +1,138 @@
1
+ import { getKeybindings, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+
3
+ import { sanitizeTerminalText } from "./format-output.mjs";
4
+ import { screenTranslator } from "../../i18n/index.mjs";
5
+
6
+ // NetEase plays through its own command line tool and mpv, set up from a terminal as the window shows it.
7
+ export const NETEASE_SETUP = "npm install -g @music163/ncm-cli && brew install mpv && ncm-cli login";
8
+
9
+ /** The command that makes NetEase ready, from ncm-cli's state: missing installs it, signed out signs in. */
10
+ export function neteaseSetupCommand(state) {
11
+ return state === "signed_out" ? "ncm-cli login" : NETEASE_SETUP;
12
+ }
13
+
14
+ export function playbackServiceName(service, tr) {
15
+ return service === "netease" ? tr("NetEase Music") : service === "apple_music" ? "Apple Music" : "Spotify";
16
+ }
17
+
18
+ /**
19
+ * The music services the first run and /service offer, as the window does: Spotify and Apple Music,
20
+ * and NetEase once ncm-cli is installed, signed in or not. Choosing NetEase before it is signed in
21
+ * shows how to set it up.
22
+ */
23
+ export function playbackServiceChoices(tr, { netease = "unavailable", current = null } = {}) {
24
+ const neteaseOffered = ["ready", "signed_out", "missing"].includes(netease) || current === "netease";
25
+ return [
26
+ { value: "spotify", label: "Spotify", description: tr("Whole songs on your Spotify devices") },
27
+ { value: "apple_music", label: "Apple Music", description: tr("30-second previews, played here") },
28
+ ...(neteaseOffered ? [{
29
+ value: "netease", label: tr("NetEase Music"),
30
+ description: netease === "ready" ? tr("Whole songs through ncm-cli") : tr("Set up ncm-cli first"),
31
+ }] : []),
32
+ ];
33
+ }
34
+
35
+ function clean(value) {
36
+ return sanitizeTerminalText(String(value ?? "")).replace(/\s+/gu, " ").trim();
37
+ }
38
+
39
+ function paint(value, width, theme) {
40
+ const clipped = truncateToWidth(value, width, "");
41
+ const line = clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
42
+ return theme.plain ? line.replace(/\u001b\[[\d;:]*m/g, "") : theme.background(theme.text(line));
43
+ }
44
+
45
+ /**
46
+ * The one-time first run: where music plays, then the model. It draws the step it is given and
47
+ * reports the listener's choice; the room saves it and runs Pi's own model picker and sign-in.
48
+ */
49
+ export class FirstRunView {
50
+ constructor({ getTheme, getRows, onAction, getText = () => screenTranslator("en") }) {
51
+ this.getTheme = getTheme;
52
+ this.getRows = getRows;
53
+ this.getText = getText;
54
+ this.onAction = onAction;
55
+ this.state = { step: "service", steps: 2, services: [], model: {} };
56
+ this.selected = 0;
57
+ this.focused = false;
58
+ }
59
+
60
+ invalidate() {}
61
+
62
+ setState(state) {
63
+ if (state.step !== this.state.step) this.selected = 0;
64
+ this.state = { ...this.state, ...state };
65
+ }
66
+
67
+ actions() {
68
+ const tr = this.getText();
69
+ if (this.state.step === "service") return this.state.services.map((choice) => ({ type: "service", ...choice }));
70
+ const { provider, signIn } = this.state.model ?? {};
71
+ return signIn ? [
72
+ { type: "auth", label: tr("Sign in to {provider}", { provider }), description: provider === "openai" ? tr("ChatGPT sign-in or API key") : tr("API key") },
73
+ { type: "model", label: tr("Choose another model") },
74
+ ] : [{ type: "model", label: tr("Choose a model"), description: tr("Pick a provider and model, then sign in") }];
75
+ }
76
+
77
+ handleInput(data) {
78
+ const keys = getKeybindings();
79
+ const actions = this.actions();
80
+ if (keys.matches(data, "tui.select.cancel")) {
81
+ this.onAction?.({ type: "skip" });
82
+ } else if (keys.matches(data, "tui.select.up") || keys.matches(data, "tui.select.down")) {
83
+ if (actions.length) this.selected = (this.selected + (keys.matches(data, "tui.select.up") ? -1 : 1) + actions.length) % actions.length;
84
+ } else if (keys.matches(data, "tui.select.confirm") && actions.length) {
85
+ const { type, value } = actions[Math.min(this.selected, actions.length - 1)];
86
+ this.onAction?.({ type, value });
87
+ }
88
+ }
89
+
90
+ render(width) {
91
+ const columns = Math.max(1, Math.floor(width));
92
+ const rows = Number(this.getRows());
93
+ const height = Number.isFinite(rows) ? Math.max(0, Math.floor(rows)) : 0;
94
+ if (!height) return [];
95
+ const theme = this.getTheme();
96
+ const tr = this.getText();
97
+ const inset = columns >= 8 ? 1 : 0;
98
+ const inner = Math.max(1, columns - inset * 2);
99
+ const wrap = (text, ink) => wrapTextWithAnsi(clean(text), inner).map(ink);
100
+ const { step, steps, note, setup, model = {} } = this.state;
101
+ const intro = steps > 1 ? tr("Two things before we start: where I play your music, and the model I talk through.")
102
+ : step === "service" ? tr("One thing before we start: where I play your music.") : tr("One thing before we start: the model I talk through.");
103
+ const question = step === "service" ? tr("Where should I play music?") : tr("Which model should I talk through?");
104
+ const help = step === "service" ? tr("/service changes it any time.")
105
+ : model.signIn ? tr("{model} is saved, but you're not signed in to {provider} yet.", { model: `${model.provider}/${model.model}`, provider: model.provider })
106
+ : tr("Sign in with ChatGPT, or use an API key from another provider. Your profile works without one.");
107
+ const notice = [
108
+ ...(note?.text ? wrap(note.text, note.tone === "error" ? theme.error : theme.accent) : []),
109
+ ...(setup ? [...wrap(tr("NetEase plays through its own command line tool. Run this in another terminal, then choose it again:"), theme.muted),
110
+ ...wrap(setup, theme.text)] : []),
111
+ ];
112
+ const actions = this.actions();
113
+ this.selected = Math.min(this.selected, Math.max(0, actions.length - 1));
114
+ const labelWidth = Math.max(0, ...actions.map(({ label }) => visibleWidth(clean(label)))) + 4;
115
+ const items = actions.map(({ label, description }, index) => {
116
+ const selected = index === this.selected;
117
+ const name = (selected ? "◉ " : " ") + clean(label);
118
+ const room = inner - labelWidth;
119
+ const line = description && room >= 12
120
+ ? name + " ".repeat(Math.max(1, labelWidth - visibleWidth(name))) + theme.muted(truncateToWidth(clean(description), room, "…"))
121
+ : name;
122
+ return selected ? theme.selected(truncateToWidth(name, inner, "")) + line.slice(name.length) : theme.text(line);
123
+ });
124
+ // The choices always show. On a short screen the blank rows go first, then the introduction,
125
+ // then the line under the question; what happened last stays longest.
126
+ const title = theme.bold(tr("Welcome to the machine."));
127
+ const parts = (gaps, introduced, helped) => [
128
+ title, ...(introduced ? wrap(intro, theme.muted) : []), ...(gaps ? [""] : []),
129
+ ...(notice.length ? [...notice, ...(gaps ? [""] : [])] : []),
130
+ theme.bold(question), ...(helped ? wrap(help, theme.muted) : []), ...(gaps ? [""] : []),
131
+ ];
132
+ const space = height - items.length;
133
+ const above = [parts(true, true, true), parts(false, true, true), parts(false, false, true), parts(false, false, false)]
134
+ .find((candidate) => candidate.length <= space) ?? (space > 0 ? parts(false, false, false).slice(-space) : []);
135
+ const lines = [...above, ...items];
136
+ return Array.from({ length: height }, (_, row) => paint(" ".repeat(inset) + (lines[row] ?? ""), columns, theme));
137
+ }
138
+ }
@@ -557,5 +557,5 @@ export function formatLocalResult(command, value, tr = screenTranslator("en")) {
557
557
  }
558
558
 
559
559
  export function helpText(tr = screenTranslator("en")) {
560
- return tr("# Every command\n\nFrom your shell:\n\n- `moondog --version` - show the installed version\n- `moondog update [--check] [--channel latest|beta]` - check for or install a published release\n- `moondog web status|search|read [arguments] [--json]` - look things up on public music sites through DeepSeek, Brave Search or the Codex CLI\n- `moondog demo-history --output <absolute-file.zip> [--json]` - make a fictional Spotify history ZIP to try the importer with\n- `moondog auth login <provider>` - sign in to a model provider; the key is typed out of sight, and openai-codex uses your ChatGPT sign-in\n- `moondog auth status [provider]` - see which sign-ins are saved\n- `moondog auth logout <provider>` - sign out of a provider\n- `moondog spotify help` - set up and control Spotify\n- `moondog spotify login [client-id]` - connect Spotify in your browser\n- `moondog spotify now|devices|queue` - see what Spotify is playing, where, and what's next\n- `moondog spotify recent` - see what you played lately\n- `moondog spotify sync-recent` - add your latest plays to your profile\n- `moondog spotify sync-library` - add your Spotify library to your profile\n- `moondog spotify play|pause|next|previous` - control Spotify playback\n- `moondog listenbrainz import-history <listen-history.json>` - add a ListenBrainz listens file to your profile\n- `moondog catalog latest-single --artist <name> [--known-release <title> | --from <spotify-history.zip>]` - find an artist's latest single on Apple Music (US), no model needed\n- `moondog status [--json]` - see where things stand\n- `moondog taste [--json]` - your listening report\n- `moondog taste --html [--output <file.html>]` - save your report as a web page that works offline\n- `moondog taste --card [--output <file.html>]` - save a short recap you can check before sharing\n- `moondog taste --from <spotify-history.zip> --html` - a one-off report from a Spotify ZIP, without saving it\n- `moondog taste --from <account-data.zip> --from <extended-history.zip> --html` - a one-off report from both Spotify exports together\n- `moondog taste --from <spotify-history.zip> --save --html` - save the Spotify history, then show your full report\n- `moondog data inspect --scope <listening|apple|profile>` - see what's stored, and get the code needed to reset it\n- `moondog data export --scope <listening|apple|profile> --output <directory>` - export a copy, leaving the original alone\n- `moondog data reset --scope <listening|apple|profile> --confirm <token>` - set stored data aside (it is archived, not deleted)\n- `moondog profile build [--force]` - build or resume a saved profile with your connected model\n- `moondog profile saved [offset]` - read your saved profile\n- `moondog profile explain <number>` - trace a saved finding to its evidence\n- `moondog profile corrections [--all]` - everything you've told me, including earlier choices\n- `moondog profile correct --artist <name> (--like|--avoid)` - like an artist, or keep them out\n- `moondog profile correct --track <title> --by <artist> (--like|--avoid)` - like a track, or keep it out\n- `moondog profile retract <correction-id>` - undo one of your choices\n- `moondog demo --offline [--json]` - a demo with made-up data, no sign-in needed\n- `moondog demo [prompt] [--json]` - the same demo, talking through your model\n- `moondog ask <prompt>` - ask one question and exit\n- `moondog remember <text>` - ask me to remember something\n- `moondog forget <memory-id>` - forget something\n- `moondog memory reflect [--dry-run] [--json]` - let me think over recent conversations now\n- `moondog` - open the listening room\n\nInside the listening room:\n\n- `/update [--check] [--channel latest|beta]` - check for updates, or save and close this session to install one\n- `/status` or `/sources` - see where things stand\n- `/profile` or `/taste` - your profile, and why each song or artist is there\n- `/taste report` - the whole report on one page\n- `/profile build [--force]` - build or resume a saved reading; Ctrl+C keeps progress\n- `/profile saved [offset]` - read your saved profile\n- `/profile explain <number>` - inspect a saved finding's evidence\n- `/profile corrections [--all]` - everything you've told me\n- `/profile correct --track \"<title>\" --by \"<artist>\" (--like|--avoid)` - like a track, or keep it out\n- `/profile correct --artist \"<name>\" (--like|--avoid)` - like an artist, or keep them out\n- `/profile retract <correction-id>` - undo a choice\n- `/memory` - what I remember\n- `/tools` - what I can do right now\n- `/doctor` - check your setup\n- `/model` - pick the model I talk through\n- `/model <provider> <model>` - switch straight to a model\n- `/model refresh [provider]` - update public model choices; keep the selected model\n- `/auth [provider]` - sign in to a model provider\n- `/web status|search|read` - look things up on public music sites\n- `/import [path]` - bring in your history, library, or a playlist\n- `/language` - choose the language Moondog uses\n- `/spotify` - Spotify commands\n- `/spotify login [client-id]` - connect Spotify\n- `/spotify now|devices|queue` - see what's playing, where, and what's next\n- `/spotify recent|sync-recent` - see or save your latest plays\n- `/spotify import-history \"/path/to/spotify-history.zip\"` - add a Spotify ZIP, no sign-in needed\n- `/skip` or `/stop` - move to the next song preview, or stop them\n- `/reload` - reload model settings and sign-ins\n- `/remember [kind] <text>` - ask me to remember something\n- `/forget <memory-id>` - forget something\n- `/rewind` or Alt+R - edit an earlier message in place; the messages after it are replaced\n- `/branch` - go on from here in a new conversation; this one stays saved\n- `/rewind latest` - edit the most recent message\n- `/resume` - pick up a saved conversation\n- `/resume <session-id>` - go straight to one\n- `/new` - start fresh; the last one stays saved\n- `/quit` - exit\n\nAnything you type that isn't a command goes to the model you picked with `/model`, or the one set in `MOONDOG_PROVIDER` and `MOONDOG_MODEL`.\nSign in with `/auth`, or set the provider's API key in your environment. `/auth openai-codex` uses your ChatGPT sign-in.\nOnce your profile is ready, I can search your library, explain what I see, and plan playlists with you.\nI can only control Spotify after `moondog spotify login`, and I never post, send messages, delete anything, or spend money.\nAsk me to move playback onto a device by name, such as your iPhone.");
560
+ return tr("# Every command\n\nFrom your shell:\n\n- `moondog --version` - show the installed version\n- `moondog update [--check] [--channel latest|beta]` - check for or install a published release\n- `moondog web status|search|read [arguments] [--json]` - look things up on public music sites through DeepSeek, Brave Search or the Codex CLI\n- `moondog demo-history --output <absolute-file.zip> [--json]` - make a fictional Spotify history ZIP to try the importer with\n- `moondog auth login <provider>` - sign in to a model provider; the key is typed out of sight; `openai --chatgpt` signs in with your ChatGPT subscription\n- `moondog auth status [provider]` - see which sign-ins are saved\n- `moondog auth logout <provider>` - sign out of a provider\n- `moondog spotify help` - set up and control Spotify\n- `moondog spotify login [client-id]` - connect Spotify in your browser\n- `moondog spotify now|devices|queue` - see what Spotify is playing, where, and what's next\n- `moondog spotify recent` - see what you played lately\n- `moondog spotify sync-recent` - add your latest plays to your profile\n- `moondog spotify sync-library` - add your Spotify library to your profile\n- `moondog spotify play|pause|next|previous` - control Spotify playback\n- `moondog listenbrainz import-history <listen-history.json>` - add a ListenBrainz listens file to your profile\n- `moondog catalog latest-single --artist <name> [--known-release <title> | --from <spotify-history.zip>]` - find an artist's latest single on Apple Music (US), no model needed\n- `moondog status [--json]` - see where things stand\n- `moondog taste [--json]` - your listening report\n- `moondog taste --html [--output <file.html>]` - save your report as a web page that works offline\n- `moondog taste --card [--output <file.html>]` - save a short recap you can check before sharing\n- `moondog taste --from <spotify-history.zip> --html` - a one-off report from a Spotify ZIP, without saving it\n- `moondog taste --from <account-data.zip> --from <extended-history.zip> --html` - a one-off report from both Spotify exports together\n- `moondog taste --from <spotify-history.zip> --save --html` - save the Spotify history, then show your full report\n- `moondog data inspect --scope <listening|apple|profile>` - see what's stored, and get the code needed to reset it\n- `moondog data export --scope <listening|apple|profile> --output <directory>` - export a copy, leaving the original alone\n- `moondog data reset --scope <listening|apple|profile> --confirm <token>` - set stored data aside (it is archived, not deleted)\n- `moondog profile build [--force]` - build or resume a saved profile with your connected model\n- `moondog profile saved [offset]` - read your saved profile\n- `moondog profile explain <number>` - trace a saved finding to its evidence\n- `moondog profile corrections [--all]` - everything you've told me, including earlier choices\n- `moondog profile correct --artist <name> (--like|--avoid)` - like an artist, or keep them out\n- `moondog profile correct --track <title> --by <artist> (--like|--avoid)` - like a track, or keep it out\n- `moondog profile retract <correction-id>` - undo one of your choices\n- `moondog demo --offline [--json]` - a demo with made-up data, no sign-in needed\n- `moondog demo [prompt] [--json]` - the same demo, talking through your model\n- `moondog ask <prompt>` - ask one question and exit\n- `moondog remember <text>` - ask me to remember something\n- `moondog forget <memory-id>` - forget something\n- `moondog memory reflect [--dry-run] [--json]` - let me think over recent conversations now\n- `moondog` - open the listening room\n\nInside the listening room:\n\n- `/update [--check] [--channel latest|beta]` - check for updates, or save and close this session to install one\n- `/status` or `/sources` - see where things stand\n- `/profile` or `/taste` - your profile, and why each song or artist is there\n- `/taste report` - the whole report on one page\n- `/profile build [--force]` - build or resume a saved reading; Ctrl+C keeps progress\n- `/profile saved [offset]` - read your saved profile\n- `/profile explain <number>` - inspect a saved finding's evidence\n- `/profile corrections [--all]` - everything you've told me\n- `/profile correct --track \"<title>\" --by \"<artist>\" (--like|--avoid)` - like a track, or keep it out\n- `/profile correct --artist \"<name>\" (--like|--avoid)` - like an artist, or keep them out\n- `/profile retract <correction-id>` - undo a choice\n- `/memory` - what I remember\n- `/tools` - what I can do right now\n- `/doctor` - check your setup\n- `/model` - pick the model I talk through\n- `/model <provider> <model>` - switch straight to a model\n- `/model refresh [provider]` - update public model choices; keep the selected model\n- `/auth [provider]` - sign in to a model provider\n- `/web status|search|read` - look things up on public music sites\n- `/import [path]` - bring in your history, library, or a playlist\n- `/language` - choose the language Moondog uses\n- `/service [spotify|apple_music|netease]` - choose where I play music\n- `/spotify` - Spotify commands\n- `/spotify login [client-id]` - connect Spotify\n- `/spotify now|devices|queue` - see what's playing, where, and what's next\n- `/spotify recent|sync-recent` - see or save your latest plays\n- `/spotify import-history \"/path/to/spotify-history.zip\"` - add a Spotify ZIP, no sign-in needed\n- `/skip` or `/stop` - move to the next song preview, or stop them\n- `/reload` - reload model settings and sign-ins\n- `/remember [kind] <text>` - ask me to remember something\n- `/forget <memory-id>` - forget something\n- `/rewind` or Alt+R - edit an earlier message in place; the messages after it are replaced\n- `/branch` - go on from here in a new conversation; this one stays saved\n- `/rewind latest` - edit the most recent message\n- `/resume` - pick up a saved conversation\n- `/resume <session-id>` - go straight to one\n- `/new` - start fresh; the last one stays saved\n- `/quit` - exit\n\nAnything you type that isn't a command goes to the model you picked with `/model`, or the one set in `MOONDOG_PROVIDER` and `MOONDOG_MODEL`.\nSign in with `/auth`, or set the provider's API key in your environment. `/auth openai` signs in with your ChatGPT subscription or an API key.\nOnce your profile is ready, I can search your library, explain what I see, and plan playlists with you.\nI can only control Spotify after `moondog spotify login`, and I never post, send messages, delete anything, or spend money.\nAsk me to move playback onto a device by name, such as your iPhone.");
561
561
  }
@@ -1,3 +1,5 @@
1
+ import { logoFigure } from "../brand/logo.mjs";
2
+
1
3
  export const LOGO_MOTION_FRAMES = 64;
2
4
  export const LOGO_MOTION_INTERVAL_MS = 125;
3
5
 
@@ -6,40 +8,12 @@ const turn = Math.PI * 2;
6
8
  /** Record layers from faintest to strongest, matching the logo's fine grooves and solid dog. */
7
9
  export const RECORD_LAYERS = Object.freeze({ groove: 1, surface: 2, figure: 3 });
8
10
 
9
- // The dog's eye, in the record's unit coordinates; layouts line the title up with it.
11
+ // Where the dog's eye would be, in the record's unit coordinates; layouts line the title up with it.
12
+ // The logo draws no eye, so neither does the record.
10
13
  const eye = [0.22, -0.13];
11
14
 
12
- // The dog's outline runs through these points as one smooth curve (smoothOutline, below), so it
13
- // keeps its shape without the facets of straight segments between them.
14
- const dogPoints = [
15
- [-0.62, 0.48], [-0.55, 0.29], [-0.47, 0.08], [-0.42, -0.16],
16
- [-0.4, -0.34], [-0.32, -0.47], [-0.18, -0.61], [-0.17, -0.44],
17
- [-0.12, -0.43], [-0.1, -0.32], [0.02, -0.27], [0.16, -0.24],
18
- [0.24, -0.18], [0.28, -0.08], [0.35, -0.03], [0.53, 0.015],
19
- [0.55, 0.055], [0.53, 0.16], [0.46, 0.22], [0.31, 0.26],
20
- [0.16, 0.26], [0.13, 0.33], [0.13, 0.56], [0.045, 0.58],
21
- [0.07, 0.77], [-0.13, 0.75], [-0.3, 0.7], [-0.48, 0.62],
22
- ];
23
-
24
- // A closed centripetal Catmull-Rom curve through `points`: it passes through every point, and unlike
25
- // the uniform kind it never loops or overshoots at the sharp turns of the ear and the nose.
26
- function smoothOutline(points, steps = 10) {
27
- const outline = [];
28
- const count = points.length;
29
- for (let index = 0; index < count; index += 1) {
30
- const p = [-1, 0, 1, 2].map((offset) => points[(index + offset + count) % count]);
31
- const t = [0];
32
- for (let at = 1; at < 4; at += 1) t.push(t[at - 1] + Math.hypot(p[at][0] - p[at - 1][0], p[at][1] - p[at - 1][1]) ** 0.5);
33
- for (let step = 0; step < steps; step += 1) {
34
- const u = t[1] + (t[2] - t[1]) * step / steps;
35
- const mix = (a, b, from, to) => a.map((value, axis) => ((to - u) * value + (u - from) * b[axis]) / (to - from));
36
- const [a1, a2, a3] = [mix(p[0], p[1], t[0], t[1]), mix(p[1], p[2], t[1], t[2]), mix(p[2], p[3], t[2], t[3])];
37
- outline.push(mix(mix(a1, a2, t[0], t[2]), mix(a2, a3, t[1], t[3]), t[1], t[2]));
38
- }
39
- }
40
- return outline;
41
- }
42
- const dog = smoothOutline(dogPoints);
15
+ // The dog and its headphones, measured from the logo (src/surfaces/brand/logo.mjs).
16
+ const figure = logoFigure();
43
17
 
44
18
  const smallAscii = [
45
19
  " /",
@@ -111,17 +85,6 @@ function insidePolygon(x, y, points) {
111
85
  return inside;
112
86
  }
113
87
 
114
- function bezier(start, first, second, end) {
115
- return Array.from({ length: 65 }, (_, index) => {
116
- const t = index / 64;
117
- const s = 1 - t;
118
- return [0, 1].map((axis) =>
119
- s ** 3 * start[axis] + 3 * s ** 2 * t * first[axis]
120
- + 3 * s * t ** 2 * second[axis] + t ** 3 * end[axis],
121
- );
122
- });
123
- }
124
-
125
88
  function drawing(columns, rows, cellAspect) {
126
89
  const width = columns * 2;
127
90
  const height = rows * 4;
@@ -182,11 +145,8 @@ function drawing(columns, rows, cellAspect) {
182
145
  return { dots, width, height, radius, centerX, centerY, pixel, circle, disc, line, polygon, paintLayer };
183
146
  }
184
147
 
185
- // The portrait and the tonearm in the record's unit coordinates: the disc has radius 1 around 0,0.
148
+ // The tonearm in the record's unit coordinates: the disc has radius 1 around 0,0.
186
149
  const shapes = {
187
- headband: bezier([-0.42, -0.03], [-0.47, -0.31], [-0.27, -0.58], [-0.18, -0.59]),
188
- ear: [-0.365, -0.005],
189
- nose: [0.53, 0.045],
190
150
  arm: [[1.17, -1.095], [0.91, -0.8]],
191
151
  cartridge: [[0.92, -0.845], [0.998, -0.777], [0.808, -0.567], [0.73, -0.635]],
192
152
  // Two short highlights ride fixed grooves as the record turns: the groove's place from the rim, its angle, its length.
@@ -195,7 +155,7 @@ const shapes = {
195
155
 
196
156
  /** The shapes the record is drawn from, for surfaces that draw it as vectors, such as the window's small mark. */
197
157
  export function lunarRecordShapes() {
198
- return structuredClone({ dog, eye, ...shapes, earRadius: 0.17, grooves: [1, 0.91, 0.8, 0.66] });
158
+ return structuredClone({ eye, ...shapes, grooves: [1, 0.91, 0.8, 0.66] });
199
159
  }
200
160
 
201
161
  // The tonearm swings about its pivot, the arm's first point; a negative swing turns it off the record.
@@ -261,25 +221,23 @@ function paint(columns, rows, phase, cellAspect, tonearm = true) {
261
221
 
262
222
  paintLayer(RECORD_LAYERS.figure);
263
223
  // Like the logo, a clear halo separates the dog from the grooves around it.
264
- const { headband, ear, nose } = shapes;
265
- line([...dog, dog[0]], pixel * 4.5, false);
266
- line(headband, Math.max(pixel * 7, 0.11), false);
267
- polygon(dog, false);
268
- polygon(dog, true, true);
269
- line([...dog, dog[0]], pixel * 0.8);
270
-
271
- line(headband, Math.max(pixel * 3.5, 0.065));
272
- line(headband, Math.max(pixel * 1.2, 0.025), false);
273
- const [earX, earY] = ear;
274
- const earRadius = detail > 0 ? 0.157 : 0.17;
275
- disc(earX, earY, earRadius + pixel * 1.6, false);
276
- circle(earX, earY, earRadius, pixel * 1.1);
277
- circle(earX, earY, earRadius * 0.69, pixel);
278
- if (detail > 0) circle(earX, earY, earRadius * 0.35, pixel);
224
+ const { head, headEdge, bandCenter, bandGap, gap, cup } = figure;
225
+ line(headEdge, pixel * 4.5, false);
226
+ line(bandCenter, Math.max(pixel * 7, 0.11), false);
227
+ polygon(head, false);
228
+ polygon(head, true, true);
229
+ line(headEdge, pixel * 0.8);
230
+ // As in the logo, a gap parts the band from the head.
231
+ line(bandGap, Math.max(gap, pixel * 2), false);
232
+
233
+ line(bandCenter, Math.max(pixel * 3.5, 0.065));
234
+ line(bandCenter, Math.max(pixel * 1.2, 0.025), false);
235
+ const [earX, earY] = cup.center;
236
+ disc(earX, earY, cup.radius + pixel * 1.6, false);
237
+ circle(earX, earY, cup.radius, pixel * 1.1);
238
+ circle(earX, earY, cup.radius * 0.69, pixel);
239
+ if (detail > 0) circle(earX, earY, cup.radius * 0.35, pixel);
279
240
  else disc(earX, earY, pixel * 0.9);
280
- // A small eye and nose keep the dog readable before the hatch pattern resolves.
281
- disc(eye[0], eye[1], pixel * 1.05, false);
282
- disc(nose[0], nose[1], pixel * 0.85);
283
241
 
284
242
  if (tonearm) paintTonearm(art, rotation);
285
243
  return art;