@pixelvibing/cli 0.58.0 → 0.59.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.
@@ -418,6 +418,66 @@ function apiAddress(environment = process.env) {
418
418
  if (!PLAIN_PATH.test(url.pathname)) throw new ApiAddressError("PIXELVIBING_API_URL's path may hold only letters, digits, and . _ ~ - /");
419
419
  return url.href.replace(/\/+$/, "");
420
420
  }
421
+ const SFX_SECONDS = { min: 0.5, max: 22 };
422
+ const MAX_SPEECH_CHARACTERS = 500;
423
+ const MAX_AUDIO_DESCRIPTION = 600;
424
+ const VOICES = [
425
+ "Aria",
426
+ "Roger",
427
+ "Sarah",
428
+ "Laura",
429
+ "Charlie",
430
+ "George",
431
+ "Callum",
432
+ "River",
433
+ "Liam",
434
+ "Charlotte",
435
+ "Alice",
436
+ "Matilda",
437
+ "Will",
438
+ "Jessica",
439
+ "Eric",
440
+ "Chris",
441
+ "Brian",
442
+ "Daniel",
443
+ "Lily",
444
+ "Bill"
445
+ ];
446
+ const MUSIC_SECONDS = { min: 3, max: 600, unasked: 60 };
447
+ const SECTION_SECONDS = { min: 3, max: 120 };
448
+ const toMs = (seconds) => Math.round(seconds * 1e3) / 1e3;
449
+ function musicSeconds(body) {
450
+ const sections = body.plan?.sections;
451
+ if (Array.isArray(sections))
452
+ return sections.reduce((sum, each) => sum + (typeof each?.seconds === "number" && Number.isFinite(each.seconds) ? toMs(each.seconds) : 0), 0);
453
+ return typeof body.seconds === "number" && Number.isFinite(body.seconds) ? toMs(body.seconds) : MUSIC_SECONDS.unasked;
454
+ }
455
+ function musicMinutes(body) {
456
+ return Math.max(1, Math.ceil(musicSeconds(body) / 60));
457
+ }
458
+ function musicProblem(body) {
459
+ const described = typeof body.description === "string" && body.description.trim() !== "";
460
+ if (body.plan === void 0) {
461
+ if (!described) return "describe the music, or give a composition plan";
462
+ if (body.seconds !== void 0 && !(typeof body.seconds === "number" && body.seconds >= MUSIC_SECONDS.min && body.seconds <= MUSIC_SECONDS.max))
463
+ return `music runs ${MUSIC_SECONDS.min} to ${MUSIC_SECONDS.max} seconds`;
464
+ return null;
465
+ }
466
+ if (body.description !== void 0) return "music takes a description or a composition plan, not both";
467
+ if (body.seconds !== void 0 || body.instrumental !== void 0)
468
+ return "a composition plan's length is its sections', and its lyrics say whether it is sung";
469
+ const sections = body.plan.sections;
470
+ if (!Array.isArray(sections) || sections.length === 0) return "a composition plan has at least one section";
471
+ for (const section2 of sections) {
472
+ const { name, seconds: seconds2 } = typeof section2 === "object" && section2 !== null ? section2 : {};
473
+ if (typeof name !== "string" || name.trim() === "") return "each section has a name";
474
+ if (!(typeof seconds2 === "number" && seconds2 >= SECTION_SECONDS.min && seconds2 <= SECTION_SECONDS.max))
475
+ return `a section runs ${SECTION_SECONDS.min} to ${SECTION_SECONDS.max} seconds, not ${String(seconds2)}`;
476
+ }
477
+ const seconds = musicSeconds(body);
478
+ if (seconds > MUSIC_SECONDS.max) return `a composition plan runs at most ${MUSIC_SECONDS.max} seconds in all, not ${seconds}`;
479
+ return null;
480
+ }
421
481
  class GuideConflict extends Error {
422
482
  constructor(current) {
423
483
  super(`the style guide is now at version ${current}`);
@@ -433,6 +493,12 @@ class TooFewCredits extends Error {
433
493
  this.credits = credits;
434
494
  }
435
495
  }
496
+ function costIn(prices, kind, body) {
497
+ const price = prices.kinds[kind];
498
+ if (price === void 0) return null;
499
+ if ("credits" in price) return price.credits;
500
+ return price.perMinute !== void 0 && body !== void 0 ? price.perMinute * musicMinutes(body) : price.upTo;
501
+ }
436
502
  const id = (value) => encodeURIComponent(value);
437
503
  function createWebApi(fetcher = (input, init2) => fetch(input, init2), base = API_BASE) {
438
504
  const call = createCaller(fetcher, base);
@@ -4890,12 +4956,14 @@ function shareWords(credits, limit) {
4890
4956
  function leftWords(usage) {
4891
4957
  return `${100 - usedPercent(usage)}% of your limit left`;
4892
4958
  }
4893
- function costShareWords(prices, kind, usage) {
4959
+ function costShareWords(prices, kind, usage, body) {
4894
4960
  const price = prices.kinds[kind];
4895
- if (price === void 0) return null;
4896
- const most = "credits" in price ? price.credits : price.upTo;
4961
+ const most = costIn(prices, kind, body);
4962
+ if (price === void 0 || most === null) return null;
4897
4963
  const share = `${shareWords(most, usage.limit)} of your limit`;
4898
- return "credits" in price ? share : `up to ${share}, charged for what it uses once done`;
4964
+ if ("credits" in price) return share;
4965
+ if (price.perMinute !== void 0 && body !== void 0) return `${share}, by the minute of its length`;
4966
+ return `up to ${share}, charged for what it uses once done`;
4899
4967
  }
4900
4968
  const LARGEST_FOOTPRINT = {
4901
4969
  cols: Math.min(Math.floor(WINDOW_PX.width / SCALES.at(-1)), Math.floor(MAX_ASSEMBLED_PX / TILE_SIZE)),
@@ -4917,31 +4985,6 @@ function drawCanvas(footprint) {
4917
4985
  if (pxPerTile === void 0) return null;
4918
4986
  return { width: footprint.cols * pxPerTile, height: footprint.rows * pxPerTile, pxPerTile };
4919
4987
  }
4920
- const SFX_SECONDS = { min: 0.5, max: 22 };
4921
- const MAX_SPEECH_CHARACTERS = 500;
4922
- const MAX_AUDIO_DESCRIPTION = 600;
4923
- const VOICES = [
4924
- "Aria",
4925
- "Roger",
4926
- "Sarah",
4927
- "Laura",
4928
- "Charlie",
4929
- "George",
4930
- "Callum",
4931
- "River",
4932
- "Liam",
4933
- "Charlotte",
4934
- "Alice",
4935
- "Matilda",
4936
- "Will",
4937
- "Jessica",
4938
- "Eric",
4939
- "Chris",
4940
- "Brian",
4941
- "Daniel",
4942
- "Lily",
4943
- "Bill"
4944
- ];
4945
4988
  const PATCH_CORNERS = [
4946
4989
  [1, 0, 0, 0, 0],
4947
4990
  [1, 0, 0, 1, 0],
@@ -5777,8 +5820,10 @@ const OWN_OPTIONS = {
5777
5820
  terrain: ["terrain-animation"],
5778
5821
  text: ["speech"],
5779
5822
  voice: ["speech"],
5780
- seconds: ["sfx"],
5823
+ seconds: ["sfx", "music"],
5781
5824
  loop: ["sfx"],
5825
+ instrumental: ["music"],
5826
+ plan: ["music"],
5782
5827
  out: ["sfx", "speech", "music", "vfx"],
5783
5828
  preset: ["vfx"],
5784
5829
  part: ["vfx"],
@@ -5819,8 +5864,18 @@ const MORE_OPTIONS = {
5819
5864
  terrain: { type: "string", value: "<asset id|name>", description: "the terrain set an animated terrain set moves" },
5820
5865
  text: { type: "string", value: "<text>", description: "what speech says" },
5821
5866
  voice: { type: "string", value: "<voice>", description: `the voice speech is said in: ${VOICES.join(", ")}` },
5822
- seconds: { type: "string", value: "<seconds>", description: `a sound effect's length, ${SFX_SECONDS.min} to ${SFX_SECONDS.max}` },
5867
+ seconds: {
5868
+ type: "string",
5869
+ value: "<seconds>",
5870
+ description: `a sound effect's length, ${SFX_SECONDS.min} to ${SFX_SECONDS.max}; music's, ${MUSIC_SECONDS.min} to ${MUSIC_SECONDS.max}, ${MUSIC_SECONDS.unasked} unless given`
5871
+ },
5823
5872
  loop: { type: "boolean", description: "a sound effect that loops seamlessly" },
5873
+ instrumental: { type: "boolean", description: "music with no voice" },
5874
+ plan: {
5875
+ type: "string",
5876
+ value: "<json>",
5877
+ description: `music section by section, instead of --instruction: a JSON file of { styles, avoid, sections: [{ name, seconds (${SECTION_SECONDS.min}-${SECTION_SECONDS.max}), styles, avoid, lyrics }], flexibleLengths }, at most ${MUSIC_SECONDS.max} seconds in all`
5878
+ },
5824
5879
  out: {
5825
5880
  type: "string",
5826
5881
  value: "<path>",
@@ -5995,9 +6050,9 @@ async function moreRequest(context, kind, args, instruction, reference) {
5995
6050
  if (!VOICES.includes(voice)) throw new CliError(`--voice is one of ${VOICES.join(", ")}`);
5996
6051
  return { kind, body: { text: text2, voice }, words: `${words} in ${voice}: ${text2}` };
5997
6052
  }
6053
+ if (kind === "music") return musicRequestOf(context, args, instruction);
5998
6054
  if (instruction === "") throw new CliError(`describe ${words} with --instruction`);
5999
6055
  if (instruction.length > MAX_AUDIO_DESCRIPTION) throw new CliError(`a description is at most ${MAX_AUDIO_DESCRIPTION} characters`);
6000
- if (kind === "music") return { kind, body: { description: instruction }, words: `${words}: ${instruction}` };
6001
6056
  const seconds = stringOf(args, "seconds");
6002
6057
  const length = seconds === void 0 ? void 0 : Number(seconds);
6003
6058
  if (length !== void 0 && !(length >= SFX_SECONDS.min && length <= SFX_SECONDS.max))
@@ -6008,6 +6063,54 @@ async function moreRequest(context, kind, args, instruction, reference) {
6008
6063
  words: `${words}${length === void 0 ? "" : ` of ${length} s`}${flagOf(args, "loop") ? ", looping" : ""}: ${instruction}`
6009
6064
  };
6010
6065
  }
6066
+ const MAX_PLAN_BYTES = 128 * 1024;
6067
+ async function planOf(context, path) {
6068
+ const file = resolve(context.io.cwd, path);
6069
+ let text2;
6070
+ try {
6071
+ const found = await stat(file);
6072
+ if (!found.isFile()) throw new CliError(`the plan ${path} is not a file`);
6073
+ if (found.size > MAX_PLAN_BYTES) throw new CliError(`the plan ${path} is larger than ${MAX_PLAN_BYTES} bytes`);
6074
+ text2 = await readFile(file, "utf8");
6075
+ } catch (error) {
6076
+ if (error instanceof CliError) throw error;
6077
+ throw new CliError(`the plan ${path} cannot be read`);
6078
+ }
6079
+ let plan;
6080
+ try {
6081
+ plan = JSON.parse(text2);
6082
+ } catch {
6083
+ throw new CliError(`the plan ${path} is not JSON`);
6084
+ }
6085
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan))
6086
+ throw new CliError(`the plan ${path} is not a plan: { "styles": [...], "sections": [{ "name": "intro", "seconds": 20 }] }`);
6087
+ return plan;
6088
+ }
6089
+ async function musicRequestOf(context, args, instruction) {
6090
+ const path = stringOf(args, "plan");
6091
+ const given2 = stringOf(args, "seconds");
6092
+ if (path !== void 0) {
6093
+ if (instruction !== "") throw new CliError("music takes --instruction or --plan, not both");
6094
+ if (given2 !== void 0 || flagOf(args, "instrumental"))
6095
+ throw new CliError("a plan's length is its sections', and its lyrics say whether it is sung: --seconds and --instrumental are not a plan's");
6096
+ const body = { plan: await planOf(context, path) };
6097
+ const problem = musicProblem(body);
6098
+ if (problem !== null) throw new CliError(`the plan ${path}: ${problem}`);
6099
+ const sections = body.plan.sections.map((section2) => String(section2.name)).join(", ");
6100
+ return { kind: "music", body, words: `music of ${musicMinutes(body)} min from ${path}: ${sections}` };
6101
+ }
6102
+ if (instruction === "") throw new CliError("describe the music with --instruction, or give its sections with --plan");
6103
+ if (instruction.length > MAX_AUDIO_DESCRIPTION) throw new CliError(`a description is at most ${MAX_AUDIO_DESCRIPTION} characters`);
6104
+ const seconds = given2 === void 0 ? void 0 : Number(given2);
6105
+ if (seconds !== void 0 && !(seconds >= MUSIC_SECONDS.min && seconds <= MUSIC_SECONDS.max))
6106
+ throw new CliError(`--seconds is ${MUSIC_SECONDS.min} to ${MUSIC_SECONDS.max} for music`);
6107
+ const instrumental = flagOf(args, "instrumental");
6108
+ return {
6109
+ kind: "music",
6110
+ body: { description: instruction, ...seconds === void 0 ? {} : { seconds }, ...instrumental ? { instrumental: true } : {} },
6111
+ words: `music of ${seconds ?? MUSIC_SECONDS.unasked} s${instrumental ? ", instrumental" : ""}: ${instruction}`
6112
+ };
6113
+ }
6011
6114
  async function withPatch(platform, project, request) {
6012
6115
  const { terrain, ...body } = request.body;
6013
6116
  const asset = await platform.web.asset(project, terrain);
@@ -6303,9 +6406,9 @@ async function shapedRequest(context, asked, args) {
6303
6406
  if (stringOf(args, "tiles") !== void 0) throw new CliError("--tiles is a pixel-art concept art's");
6304
6407
  return { kind, body, ...references.length > 0 ? { images: { references } } : {}, words };
6305
6408
  }
6306
- async function confirmed(context, platform, what, prices, kind, yes) {
6409
+ async function confirmed(context, platform, what, prices, kind, yes, body) {
6307
6410
  const usage = usageOf$1(await platform.web.billing());
6308
- const share = costShareWords(prices, kind, usage);
6411
+ const share = costShareWords(prices, kind, usage, body);
6309
6412
  const priced = share === null ? "using what the platform will say of your limit" : `using ${share}`;
6310
6413
  context.io.warn(`Making ${what}, ${priced}; ${leftWords(usage)}.
6311
6414
  `);
@@ -6331,7 +6434,7 @@ async function followed(context, platform, jobId) {
6331
6434
  const generate = {
6332
6435
  name: "generate",
6333
6436
  summary: "Generate an asset, a change of a picture, terrain, a sound or a visual effect on the platform, then pull it, keep it or write it",
6334
- usage: "<object|creature|concept-art|icons|ui|variant|edit|animate|pose|rotate|state|terrain|terrain-animation|sfx|speech|music|vfx> [--instruction <text>] [--source <image id>] [--state <state>] [--into <sprite> --name <name>] [--upper <ground> --lower <ground>] [--terrain <asset>] [--text <text> --voice <voice>] [--preset <preset> | --part <part>…] [--subject <image id>] [--out <path>] [--size <c>x<r>|<w>x<h>|<px>] [--transparent] [--pixel-art] [--tiles <c>x<r>] [--tag <tag>]… [--mask <png>] [--names <list>] [--purpose skill|item] [--sketch <json>] [--reference <png>]… [--no-enrich] [--yes]",
6437
+ usage: "<object|creature|concept-art|icons|ui|variant|edit|animate|pose|rotate|state|terrain|terrain-animation|sfx|speech|music|vfx> [--instruction <text>] [--source <image id>] [--state <state>] [--into <sprite> --name <name>] [--upper <ground> --lower <ground>] [--terrain <asset>] [--text <text> --voice <voice>] [--seconds <s>] [--loop] [--instrumental] [--plan <json>] [--preset <preset> | --part <part>…] [--subject <image id>] [--out <path>] [--size <c>x<r>|<w>x<h>|<px>] [--transparent] [--pixel-art] [--tiles <c>x<r>] [--tag <tag>]… [--mask <png>] [--names <list>] [--purpose skill|item] [--sketch <json>] [--reference <png>]… [--no-enrich] [--yes]",
6335
6438
  options: {
6336
6439
  instruction: {
6337
6440
  type: "string",
@@ -6387,7 +6490,7 @@ const generate = {
6387
6490
  const platform = await signedIn(context);
6388
6491
  const request = asked.kind === "terrain-animation" ? await withPatch(platform, project, asked) : asked;
6389
6492
  const prices = await platform.web.prices();
6390
- const uses = await confirmed(context, platform, request.words, prices, request.kind, flagOf(args, "yes"));
6493
+ const uses = await confirmed(context, platform, request.words, prices, request.kind, flagOf(args, "yes"), request.body);
6391
6494
  const jobId = await paying(platform, () => startGeneration(platform.web, project, request));
6392
6495
  context.io.warn(`Started the job ${jobId}.
6393
6496
  `);
@@ -7377,7 +7480,7 @@ function terminalOf(streams) {
7377
7480
  step: (title, at = "step") => drawStep(streams.stderr, title, at)
7378
7481
  };
7379
7482
  }
7380
- const version = "0.58.0";
7483
+ const version = "0.59.0";
7381
7484
  const manifest = {
7382
7485
  version
7383
7486
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelvibing/cli",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "PixelVibing: pixel art game assets from the command line, and the Skills that teach coding assistants to make them.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://pixelvibing.com",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pixelvibing-sound
3
- description: Generate sounds for this game on PixelVibing — a sound effect such as a coin picked up or a door creaking, a line of speech in a voice, or a piece of music — with pixel-vibing-cli or the PixelVibing MCP server, written as MP3 files. Use when the game needs audio.
3
+ description: Generate sounds for this game on PixelVibing — a sound effect such as a coin picked up or a door creaking, a line of speech in a voice, or a piece of music up to ten minutes long, from a description or section by section — with pixel-vibing-cli or the PixelVibing MCP server, written as MP3 files. Use when the game needs audio.
4
4
  ---
5
5
 
6
6
  # Sound
@@ -14,7 +14,23 @@ Load the `pixelvibing` Skill first: who is signed in, the limit.
14
14
  ```bash
15
15
  pixel-vibing-cli generate sfx --instruction "<what it sounds like>" [--seconds <s>] [--loop] [--out <file>] --yes --json
16
16
  pixel-vibing-cli generate speech --text "<what is said>" --voice <voice> [--out <file>] --yes --json
17
- pixel-vibing-cli generate music --instruction "<the piece>" [--out <file>] --yes --json
17
+ pixel-vibing-cli generate music --instruction "<the piece>" [--seconds <3-600>] [--instrumental] [--out <file>] --yes --json
18
+ pixel-vibing-cli generate music --plan <plan.json> [--out <file>] --yes --json
19
+ ```
20
+
21
+ A plan's file is the track section by section, in order:
22
+
23
+ ```json
24
+ {
25
+ "styles": ["chiptune", "heroic", "140 bpm"],
26
+ "avoid": ["vocals"],
27
+ "sections": [
28
+ { "name": "intro", "seconds": 15, "styles": ["soft pads"] },
29
+ { "name": "theme", "seconds": 90, "lyrics": ["Sail on", "the harbour waits"] },
30
+ { "name": "outro", "seconds": 20, "avoid": ["drums"] }
31
+ ],
32
+ "flexibleLengths": false
33
+ }
18
34
  ```
19
35
 
20
36
  Each writes its sound as an MP3 into `pixelvibing/sounds/`, or to the file `--out` names, once
@@ -25,7 +41,8 @@ its job is done. `pixel-vibing-cli generate --help` lists the voices.
25
41
  ```json
26
42
  { "project": "<id>", "kind": "sfx", "description": "a coin picked up, bright and short", "seconds": 1, "max_percent": 30 }
27
43
  { "project": "<id>", "kind": "speech", "text": "Welcome aboard, traveller.", "voice": "<voice>", "max_percent": 30 }
28
- { "project": "<id>", "kind": "music", "description": "a calm harbour theme on strings", "max_percent": 30 }
44
+ { "project": "<id>", "kind": "music", "description": "a calm harbour theme on strings", "seconds": 180, "instrumental": true, "max_percent": 30 }
45
+ { "project": "<id>", "kind": "music", "plan": { "styles": ["chiptune"], "sections": [{ "name": "intro", "seconds": 15 }, { "name": "loop", "seconds": 90 }] }, "max_percent": 30 }
29
46
  ```
30
47
 
31
48
  with the tool `generate_sound`. `get_job` follows the job and, once done, names its `sounds`;
@@ -39,7 +56,17 @@ with the tool `generate_sound`. `get_job` follows the job and, once done, names
39
56
  - **Speech.** The words exactly as they are said, in a voice. Keep a line short; one line a
40
57
  generation.
41
58
  - **Music.** The mood, the instruments and the tempo: "a calm harbour theme on strings and a
42
- soft accordion".
59
+ soft accordion". It runs 60 seconds unless `--seconds` (`seconds`) says otherwise, from 3 to
60
+ 600. A game's theme, a level's loop or a menu track is asked at its full length: a short one
61
+ ends where it stops. `--instrumental` (`instrumental`) makes one with no voice.
62
+ - **Music section by section.** Give a plan instead of a description when the track has parts
63
+ that must land in order: an intro, a loop, a boss phase, an ending. Each section is 3 to 120
64
+ seconds, with its own styles, styles to avoid, and lyrics for its sung lines (none for an
65
+ instrumental part); the track is at most 600 seconds in all. `flexibleLengths`
66
+ (`flexible_lengths` on the MCP server) lets each section's length move while the track's stays.
67
+ - **What music uses.** Music uses the limit by the minute of its length, rounded up: a
68
+ four-minute theme uses four times a one-minute one. Say so before asking for a long track, and
69
+ keep `max_percent` to what the person agreed.
43
70
  - **Original sound.** Never another game's music or theme, and never a real person's voice:
44
71
  when the person names one, make original sound in its spirit and say so.
45
72
  - **Where it goes.** The game loads the MP3 from where it was written; copy it into the game's