genshin-persona 4.2.0 → 4.3.1

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 (103) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +35 -34
  3. package/hooks/hooks.json +9 -1
  4. package/mod/VerbDescriptionMap.ts +3 -1
  5. package/package.json +5 -7
  6. package/scripts/pick.ts +4 -2
  7. package/scripts/react.mjs +5 -0
  8. package/scripts/react.ts +34 -0
  9. package/scripts/spinner.ts +3 -4
  10. package/scripts/voice.ts +51 -26
  11. package/skills/genshin-author/SKILL.md +1 -1
  12. package/src/generated/characterLines/ChineseSimplified.json +993 -15
  13. package/src/generated/characterLines/ChineseTraditional.json +993 -15
  14. package/src/generated/characterLines/English.json +991 -13
  15. package/src/generated/characterLines/French.json +990 -12
  16. package/src/generated/characterLines/German.json +993 -15
  17. package/src/generated/characterLines/Indonesian.json +996 -18
  18. package/src/generated/characterLines/Italian.json +991 -13
  19. package/src/generated/characterLines/Japanese.json +998 -20
  20. package/src/generated/characterLines/Korean.json +994 -16
  21. package/src/generated/characterLines/Portuguese.json +991 -13
  22. package/src/generated/characterLines/Russian.json +993 -15
  23. package/src/generated/characterLines/Spanish.json +993 -15
  24. package/src/generated/characterLines/Thai.json +994 -16
  25. package/src/generated/characterLines/Turkish.json +992 -14
  26. package/src/generated/characterLines/Vietnamese.json +993 -15
  27. package/src/generated/genshinText/generated/text/ChineseSimplified.json +283 -24
  28. package/src/generated/genshinText/generated/text/ChineseTraditional.json +283 -24
  29. package/src/generated/genshinText/generated/text/English.json +283 -24
  30. package/src/generated/genshinText/generated/text/French.json +283 -24
  31. package/src/generated/genshinText/generated/text/German.json +283 -24
  32. package/src/generated/genshinText/generated/text/Indonesian.json +283 -24
  33. package/src/generated/genshinText/generated/text/Italian.json +283 -24
  34. package/src/generated/genshinText/generated/text/Japanese.json +283 -24
  35. package/src/generated/genshinText/generated/text/Korean.json +283 -24
  36. package/src/generated/genshinText/generated/text/Portuguese.json +283 -24
  37. package/src/generated/genshinText/generated/text/Russian.json +283 -24
  38. package/src/generated/genshinText/generated/text/Spanish.json +284 -25
  39. package/src/generated/genshinText/generated/text/Thai.json +283 -24
  40. package/src/generated/genshinText/generated/text/Turkish.json +283 -24
  41. package/src/generated/genshinText/generated/text/Vietnamese.json +283 -24
  42. package/src/generated/genshinText/models/GameTextKey.ts +359 -9
  43. package/src/generated/genshinText/services/ElementColorMap.ts +11 -0
  44. package/src/localizations/chineseSimplified.ts +10 -0
  45. package/src/localizations/chineseTraditional.ts +10 -0
  46. package/src/localizations/english.ts +12 -1
  47. package/src/localizations/french.ts +12 -0
  48. package/src/localizations/german.ts +12 -0
  49. package/src/localizations/indonesian.ts +11 -0
  50. package/src/localizations/italian.ts +12 -0
  51. package/src/localizations/japanese.ts +11 -0
  52. package/src/localizations/korean.ts +10 -0
  53. package/src/localizations/portuguese.ts +12 -0
  54. package/src/localizations/russian.ts +11 -0
  55. package/src/localizations/spanish.ts +12 -0
  56. package/src/localizations/thai.ts +10 -0
  57. package/src/localizations/turkish.ts +12 -0
  58. package/src/localizations/vietnamese.ts +11 -0
  59. package/src/models/AudioPlayer.ts +7 -3
  60. package/src/models/Card.ts +4 -0
  61. package/src/models/Character.ts +2 -0
  62. package/src/models/GenshinVerb.ts +1 -0
  63. package/src/models/LocalizationStrings.ts +10 -0
  64. package/src/models/LoreRate.ts +7 -0
  65. package/src/models/ViewerCaption.ts +7 -0
  66. package/src/models/ViewerConfiguration.ts +9 -0
  67. package/src/models/ViewerEvent.ts +4 -0
  68. package/src/models/ViewerLink.ts +11 -0
  69. package/src/models/ViewerMessage.ts +10 -0
  70. package/src/models/ViewerMoment.ts +8 -0
  71. package/src/models/ViewerSoundForm.ts +8 -0
  72. package/src/models/VoiceSynthesizer.ts +1 -11
  73. package/src/services/checkIsAudible.ts +5 -0
  74. package/src/services/checkIsModelId.ts +2 -0
  75. package/src/services/checkIsSpeech.ts +4 -3
  76. package/src/services/checkIsViewerAddress.ts +5 -0
  77. package/src/services/checkIsViewerMoment.ts +9 -0
  78. package/src/services/cli/commands/genshinCommand.ts +2 -0
  79. package/src/services/cli/commands/viewerCommand.ts +78 -0
  80. package/src/services/connectViewer.ts +152 -0
  81. package/src/services/constants.ts +34 -11
  82. package/src/services/createReadingPlayer.ts +35 -0
  83. package/src/services/createViewerOpener.ts +39 -0
  84. package/src/services/createViewerPlayer.ts +80 -0
  85. package/src/services/createVoiceSynthesizer.ts +0 -147
  86. package/src/services/formatLoreChart.ts +11 -24
  87. package/src/services/getCard.ts +3 -1
  88. package/src/services/getLoreRates.ts +24 -0
  89. package/src/services/getNameplateColor.ts +3 -4
  90. package/src/services/getRosterCachePath.ts +10 -2
  91. package/src/services/getSessionStartOutput.ts +21 -4
  92. package/src/services/getViewerFrameColor.ts +8 -0
  93. package/src/services/getViewerReaction.ts +16 -0
  94. package/src/services/readMotions.ts +15 -0
  95. package/src/services/readRoster.ts +1 -0
  96. package/src/services/readViewerConfiguration.ts +21 -0
  97. package/src/services/resolveSessionCharacter.ts +2 -2
  98. package/src/services/runHookCommand.ts +19 -0
  99. package/src/services/setViewer.ts +27 -0
  100. package/src/services/writeViewerConfiguration.ts +8 -0
  101. package/src/services/ElementColorMap.ts +0 -12
  102. package/src/services/createChunkSplicer.ts +0 -47
  103. package/src/services/vocodeSpeechTokens.ts +0 -26
@@ -4,7 +4,7 @@
4
4
  "displayName": "Genshin Persona",
5
5
  "description": "Speaks as the Genshin Impact character whose birthday is nearest to today: an answer opens with one spoken line in the character's voice and the rest is plain, a joke or a hello is spoken whole, and every line is read aloud in the character's own cloned voice as the reply is written.",
6
6
  "author": { "name": "Esposter", "url": "https://github.com/Esposter" },
7
- "homepage": "https://esposter.com/docs/infra/claude-interface/persona-plugin",
7
+ "homepage": "https://github.com/Esposter/Esposter/tree/main/openspec/specs/infra/claude-interface/persona-plugin",
8
8
  "repository": "https://github.com/Esposter/Esposter",
9
9
  "license": "Apache-2.0",
10
10
  "keywords": ["genshin", "genshin-impact", "output-style", "persona", "text-to-speech", "voice-cloning"],
package/README.md CHANGED
@@ -52,7 +52,7 @@ Spoken lines stay silent until the `voice` verb has set the engine up, once, wit
52
52
  node "<plugin root>/scripts/genshin.mjs" voice ja # or: /genshin-voice ja
53
53
  ```
54
54
 
55
- It installs the engine's runtime and weights — about a gigabyte, once — into the plugin's own state directory under `~/.claude/genshin-persona`, and ends by speaking one sentence as the session's character; replies are read aloud from the next reply on, by the plugin's own hooks module, with nothing written into your settings. Each character is read in a clone of their own voice, conditioned on one line of their performance fetched from the community wiki the first time they are picked; nothing lifted from the game ships with the plugin. How the engine runs, which device it settles on and why, and what it does when it cannot speak is the [spoken replies](https://esposter.com/docs/infra/claude-interface/spoken-replies) page.
55
+ It installs the engine's runtime and weights — about a gigabyte, once — into the plugin's own state directory under `~/.claude/genshin-persona`, and ends by speaking one sentence as the session's character; replies are read aloud from the next reply on, by the plugin's own hooks module, with nothing written into your settings. Each character is read in a clone of their own voice, conditioned on one line of their performance fetched from the community wiki the first time they are picked; nothing lifted from the game ships with the plugin. How the engine runs, which device it settles on and why, and what it does when it cannot speak is the [spoken replies](https://github.com/Esposter/Esposter/blob/main/openspec/specs/infra/claude-interface/spoken-replies/design.md) page.
56
56
 
57
57
  A [TypeSafe](https://typesafe.ai) API key, given as an option, turns the pick over to lore — one typed decision over the whole roster, weighing the date, the season's festivals and your moment — instead of the nearest birthday. The key is sensitive, and the variable the SDK itself reads, `TYPESAFE_API_KEY`, is honoured when the option is empty:
58
58
 
@@ -62,7 +62,7 @@ claude plugin install genshin-persona@esposter --config typesafe_key=<key>
62
62
 
63
63
  ## <a name="documentation">📖 Documentation</a>
64
64
 
65
- We highly recommend you take a look at the [documentation](https://esposter.com/docs/infra/claude-interface/persona-plugin) to level up.
65
+ We highly recommend you take a look at the [documentation](https://github.com/Esposter/Esposter/tree/main/openspec/specs/infra/claude-interface/persona-plugin) to level up.
66
66
 
67
67
  ### Command reference
68
68
 
@@ -75,21 +75,22 @@ node "<plugin root>/scripts/genshin.mjs" <verb> [argument]
75
75
 
76
76
  A `<name>` is a character's, matched whole and ignoring case, in English or as the interface language spells it. A `[language]` is one the `language` verb lists, typed in English or in its own words. A verb given a name the roster has no character by, or an argument outside its set, prints why and exits 1; a verb with no argument where one is optional reports instead of changing anything.
77
77
 
78
- | Verb | Argument | What it does |
79
- | :--------- | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
- | `today` | — | Prints the card of this session's character; from a shell, of the pinned character, else of a fresh pick. A pin naming nobody in the roster is reported and ignored. |
81
- | `roster` | — | Every playable character, one line each: name, title, element, region, birthday and the patch that introduced them. |
82
- | `use` | `<name>` | Speaks as one character for this session alone, from that reply on; other sessions and the next start are untouched. From a shell, where no session is running, exits 1. |
83
- | `pin` | `<name>` | Fixes one character for every session until unpinned, and for this session from that reply on. |
84
- | `unpin` | — | Removes the pin; the pick decides again from the next session, and for this session from that reply on. |
85
- | `voice` | `[en \| ja \| ko \| zh]` | Sets up spoken lines in that dub — installing the engine's runtime and weights on the first run, about a gigabyte — and ends by speaking one sentence; replies are read from the next reply on. With no dub, reports what is set up. |
86
- | `mute` | — | Stops replies' spoken lines being read aloud. The pick and the card are unaffected. |
87
- | `unmute` | — | Lets replies' spoken lines be read aloud again. |
88
- | `volume` | `<0–100>` | Sets how loud replies are spoken, as a whole number, from the next reply on; `0` is silence the engine is not woken for. |
89
- | `language` | `[language]` | Sets the interface language — every word the plugin writes — and carries the reply language with it. With none, reports what is set and lists the languages on offer. |
90
- | `reply` | `[language]` | Sets the language replies are written in, on its own. With none, reports what is set and whether it was set or cascaded from the interface language. |
91
- | `status` | — | Reports every setting at once and where each value came from. Changes nothing. |
92
- | `teardown` | — | Removes what `voice` installed: the runtime, the weights, the cached clips and the dub. The picks, the pin and the languages stay; `voice` brings spoken lines back. |
78
+ | Verb | Argument | What it does |
79
+ | :--------- | :----------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
+ | `today` | — | Prints the card of this session's character; from a shell, of the pinned character, else of a fresh pick. A pin naming nobody in the roster is reported and ignored. |
81
+ | `roster` | — | Every playable character, one line each: name, title, element, region, birthday and the patch that introduced them. |
82
+ | `use` | `<name>` | Speaks as one character for this session alone, from that reply on; other sessions and the next start are untouched. From a shell, where no session is running, exits 1. |
83
+ | `pin` | `<name>` | Fixes one character for every session until unpinned, and for this session from that reply on. |
84
+ | `unpin` | — | Removes the pin; the pick decides again from the next session, and for this session from that reply on. |
85
+ | `voice` | `[en \| ja \| ko \| zh]` | Sets up spoken lines in that dub — installing the engine's runtime and weights on the first run, about a gigabyte — and ends by speaking one sentence; replies are read from the next reply on. With no dub, reports what is set up. |
86
+ | `mute` | — | Stops replies' spoken lines being read aloud. The pick and the card are unaffected. |
87
+ | `unmute` | — | Lets replies' spoken lines be read aloud again. |
88
+ | `volume` | `<0–100>` | Sets how loud replies are spoken, as a whole number, from the next reply on; `0` is silence the engine is not woken for. |
89
+ | `language` | `[language]` | Sets the interface language — every word the plugin writes — and carries the reply language with it. With none, reports what is set and lists the languages on offer. |
90
+ | `reply` | `[language]` | Sets the language replies are written in, on its own. With none, reports what is set and whether it was set or cascaded from the interface language. |
91
+ | `status` | — | Reports every setting at once and where each value came from. Changes nothing. |
92
+ | `teardown` | — | Removes what `voice` installed — the runtime, the weights, the cached clips and the dub — and the viewer’s setup. The picks, the pin and the languages stay; `voice` brings spoken lines back. |
93
+ | `viewer` | `[address] [model]` | Shows each spoken line as a bubble on a Live2DViewerEX model and sounds it there, the stock player as the fallback; with none, reports what is set. `viewer motions <attention \| turnEnd> <group:motion>` maps a motion to a moment, and a tap on the model replays the last line. |
93
94
 
94
95
  Four more are the authoring queues of one more skill, `genshin-author` — script-only and on no menu:
95
96
 
@@ -102,23 +103,23 @@ Four more are the authoring queues of one more skill, `genshin-author` — scrip
102
103
 
103
104
  ### What it ships
104
105
 
105
- | Component | Role |
106
- | :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
- | `hooks/hooks.json` | A session-start hook that picks the character, greets you with its name, what the pick weighed and who is close, and prints its card as context, and the hooks module beside it. |
108
- | `mod/` | The hooks module: the spinner's verbs and the prompt hint's tips of this session's character, the spoken replies, and one slash command per verb. |
109
- | `types/index.d.ts` | The state the hooks module publishes — the session's character and its colour — which other plugins draw by. |
110
- | `output-styles/in-character.md` | The standing rules, forced on while the plugin is enabled: a blockquote line is the character's spoken line, the ask decides how much of a reply that is, and the rest is plain. |
111
- | `skills/genshin/SKILL.md` | The model's route from a request in words to one of the verbs; hidden from the menu. |
112
- | `skills/genshin-author/SKILL.md` | How a persona card and its spinner verbs are written, the command that prints a character's own lines to write from, and the two queues. |
113
- | `src/personaCards/` | Authored persona cards, one typed module per character, in our words: how the character speaks for the model, their spinner verbs for you, and the reference line only where an ear chose one. |
114
- | `src/generated/PersonaReferenceMap.ts` | The measured reference line and its likeness per character, one generated map written by the repository's reference selection and never by hand. |
115
- | `runtime/` | The manifest and lockfile of the speech engine's runtime, which the `voice` verb installs into the state directory rather than the plugin carrying it. |
116
- | `src/localizations/` | One authored module per language for the words the data package does not carry: the base Teyvat verbs, each character's spinner gerunds, and every line the verbs print. |
117
- | `scripts/` | The session-start hook, the commands' script, the character and spinner the hooks module reads, the status line a person can name in their settings, and the resident synthesizer, each an `.mjs` that registers `tsx` before its TypeScript. |
106
+ | Component | Role |
107
+ | :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
+ | `hooks/hooks.json` | Three hooks, and the hooks module beside them. A `SessionStart` hook picks the character, greets you with its name, what the pick weighed and who is close, and prints its card as context. A `Notification` hook sends the viewer the motion mapped to attention wanted. A `Stop` hook sends the viewer the motion mapped to the end of a turn and clears its expression. |
109
+ | `mod/` | The hooks module: the spinner's verbs and the prompt hint's tips of this session's character, the spoken replies, and one slash command per verb. |
110
+ | `types/index.d.ts` | The state the hooks module publishes — the session's character and its colour — which other plugins draw by. |
111
+ | `output-styles/in-character.md` | The standing rules, forced on while the plugin is enabled: a blockquote line is the character's spoken line, the ask decides how much of a reply that is, and the rest is plain. |
112
+ | `skills/genshin/SKILL.md` | The model's route from a request in words to one of the verbs; hidden from the menu. |
113
+ | `skills/genshin-author/SKILL.md` | How a persona card and its spinner verbs are written, the command that prints a character's own lines to write from, and the two queues. |
114
+ | `src/personaCards/` | Authored persona cards, one typed module per character, in our words: how the character speaks for the model, their spinner verbs for you, and the reference line only where an ear chose one. |
115
+ | `src/generated/PersonaReferenceMap.ts` | The measured reference line and its likeness per character, one generated map written by the repository's reference selection and never by hand. |
116
+ | `runtime/` | The manifest and lockfile of the speech engine's runtime, which the `voice` verb installs into the state directory rather than the plugin carrying it. |
117
+ | `src/localizations/` | One authored module per language for the words the data package does not carry: the base Teyvat verbs, each character's spinner gerunds, and every line the verbs print. |
118
+ | `scripts/` | The session-start hook, the commands' script, the character and spinner the hooks module reads, the status line a person can name in their settings, and the resident synthesizer, each an `.mjs` that registers `tsx` before its TypeScript. |
118
119
 
119
120
  ### Spinner and prompt hint
120
121
 
121
- Nothing to set up: the hooks module turns the spinner's words into the session's character's verbs and the prompt hint into one of their lines under their name, a new one each turn, names the character among the labels at the right of the prompt's footer, and publishes the character's colour, which [`genshin-mods`](https://github.com/Esposter/Esposter/tree/main/packages/genshin-mods) draws its band in. Each session draws its own character, and a switch shows at once. Why each is shaped as it is: the [persona plugin](https://esposter.com/docs/infra/claude-interface/persona-plugin) page.
122
+ Nothing to set up: the hooks module turns the spinner's words into the session's character's verbs and the prompt hint into one of their lines under their name, a new one each turn, names the character among the labels at the right of the prompt's footer, and publishes the character's colour, which [`genshin-mods`](https://github.com/Esposter/Esposter/tree/main/packages/genshin-mods) draws its band in. Each session draws its own character, and a switch shows at once. Why each is shaped as it is: the [persona plugin](https://github.com/Esposter/Esposter/tree/main/openspec/specs/infra/claude-interface/persona-plugin) page.
122
123
 
123
124
  The plugin writes nothing into your settings, so your own status line stays yours. To have it show the character's nameplate, the name in their colour on a badge of that colour's tone, point it at the script the plugin ships:
124
125
 
@@ -136,7 +137,7 @@ node "<plugin root>/scripts/genshin.mjs" reply english # labels in one lan
136
137
  node "<plugin root>/scripts/genshin.mjs" status # every setting, and where each value came from
137
138
  ```
138
139
 
139
- The languages on offer are the data package's, so `language` with no argument lists them, each in its own words beside the word you type for it, and either resolves. What arrives localized for nothing and what is written by hand per language is the [persona plugin](https://esposter.com/docs/infra/claude-interface/persona-plugin) page's.
140
+ The languages on offer are the data package's, so `language` with no argument lists them, each in its own words beside the word you type for it, and either resolves. What arrives localized for nothing and what is written by hand per language is the [persona plugin](https://github.com/Esposter/Esposter/tree/main/openspec/specs/infra/claude-interface/persona-plugin) page's.
140
141
 
141
142
  ### How the character is picked
142
143
 
@@ -158,7 +159,7 @@ A reply to an ask of the assistant opens with one blockquote line in the charact
158
159
  node "<plugin root>/scripts/genshin.mjs" volume 60 # or: /genshin-volume 60
159
160
  ```
160
161
 
161
- `teardown` removes the runtime, the weights, the cached clips and the dub, and spoken lines stay silent until `voice` sets them up again.
162
+ `teardown` removes the runtime, the weights, the cached clips, the dub and the viewer, and spoken lines stay silent until `voice` sets them up again.
162
163
 
163
164
  ### Commands
164
165
 
@@ -170,7 +171,7 @@ pnpm lint:fix # auto-fix lint
170
171
  pnpm typecheck # type check
171
172
  ```
172
173
 
173
- The plugin holds no image, audio or text from the game: the character data arrives through its MIT-licensed dependency, and every persona card is written in our own words.
174
+ The plugin holds no image or audio from the game. Its game text, the character lines and the game's strings in fifteen languages, is committed as generated slices; the character data arrives through its MIT-licensed dependency, and every persona card is written in our own words.
174
175
 
175
176
  ## <a name="license">⚖️ License</a>
176
177
 
package/hooks/hooks.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "hooks": {
3
- "SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pick.mjs\"" }] }]
3
+ "Notification": [
4
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/react.mjs\" attention" }] }
5
+ ],
6
+ "SessionStart": [
7
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pick.mjs\"" }] }
8
+ ],
9
+ "Stop": [
10
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/react.mjs\" turnEnd" }] }
11
+ ]
4
12
  },
5
13
  "modules": ["../mod/register.ts"]
6
14
  }
@@ -11,11 +11,13 @@ export const VerbDescriptionMap: Partial<Record<GenshinVerb, string>> = {
11
11
  [GenshinVerb.Roster]: "Every playable character, one line each.",
12
12
  [GenshinVerb.Status]: "Reports every setting the plugin holds and where each came from. Changes nothing.",
13
13
  [GenshinVerb.Teardown]:
14
- "Removes the voice's runtime, weights, references and dub; the picks, the pin and the languages stay.",
14
+ "Removes the voice's runtime, weights, references and dub, and the viewer; the picks, the pin and the languages stay.",
15
15
  [GenshinVerb.Today]: "The card of this session's character.",
16
16
  [GenshinVerb.Unmute]: "Lets replies' spoken lines be read aloud again.",
17
17
  [GenshinVerb.Unpin]: "Removes the pin; the pick decides again, for this session from this reply on.",
18
18
  [GenshinVerb.Use]: "Speaks as one character for this session alone, from this reply on.",
19
+ [GenshinVerb.Viewer]:
20
+ "Shows replies' spoken lines on a Live2DViewerEX model and maps its motions to the terminal's moments; given nothing, reports the viewer on file.",
19
21
  [GenshinVerb.Voice]:
20
22
  "Sets up spoken replies in the character's own cloned voice, or switches the dub (en, ja, ko or zh); given nothing, reports what is installed.",
21
23
  [GenshinVerb.Volume]: "Sets how loud replies are spoken, as a whole number from 0 to 100.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genshin-persona",
3
- "version": "4.2.0",
3
+ "version": "4.3.1",
4
4
  "description": "A Claude Code plugin that speaks as the Genshin Impact character whose birthday is nearest: an answer opens with one spoken line in the character's voice and the rest is plain, a joke or a hello is spoken whole, and every line is read aloud in the character's own cloned voice as the reply is written.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -12,7 +12,7 @@
12
12
  "text-to-speech",
13
13
  "voice-cloning"
14
14
  ],
15
- "homepage": "https://esposter.com/docs/infra/claude-interface/persona-plugin",
15
+ "homepage": "https://github.com/Esposter/Esposter/tree/main/openspec/specs/infra/claude-interface/persona-plugin",
16
16
  "bugs": {
17
17
  "url": "https://github.com/Esposter/Esposter/issues"
18
18
  },
@@ -47,8 +47,6 @@
47
47
  "access": "public"
48
48
  },
49
49
  "scripts": {
50
- "format": "oxfmt",
51
- "format:check": "oxfmt --check",
52
50
  "lint": "eslint .",
53
51
  "lint:fix": "eslint --fix .",
54
52
  "test": "vitest",
@@ -56,14 +54,14 @@
56
54
  },
57
55
  "dependencies": {
58
56
  "@modelcontextprotocol/sdk": "^1.32.1",
59
- "@typesafe-ai/sdk": "^0.6.0",
57
+ "@typesafe-ai/sdk": "^0.7.0",
60
58
  "@wasm-audio-decoders/ogg-vorbis": "^0.1.20",
61
59
  "citty": "^0.2.2",
62
60
  "genshin-db": "^5.2.14",
63
61
  "tsx": "^4.23.15"
64
62
  },
65
63
  "engines": {
66
- "node": ">=26.11.1"
64
+ "node": ">=26.12.0"
67
65
  },
68
- "gitHead": "fef4971cf6a621a2c1850f32fcec19ee4ec0de99"
66
+ "gitHead": "777a39636b789c13af33ed13370246ccf6dbaf31"
69
67
  }
package/scripts/pick.ts CHANGED
@@ -2,6 +2,7 @@ import { deliverWarmRequest } from "#src/services/deliverWarmRequest";
2
2
  import { formatLoreChart } from "#src/services/formatLoreChart";
3
3
  import { formatUpcomingBirthdays } from "#src/services/formatUpcomingBirthdays";
4
4
  import { getCard } from "#src/services/getCard";
5
+ import { getLoreRates } from "#src/services/getLoreRates";
5
6
  import { getSessionStartOutput } from "#src/services/getSessionStartOutput";
6
7
  import { readInterfaceLanguage } from "#src/services/readInterfaceLanguage";
7
8
  import { readLocalization } from "#src/services/readLocalization";
@@ -28,16 +29,17 @@ if (pick) {
28
29
  const localization = await readLocalization(language);
29
30
  const personaCard = await readPersonaCard(character.name);
30
31
  const card = getCard(character, today, localization, personaCard);
32
+ const loreRates = loreResponse ? getLoreRates(loreResponse, roster) : [];
31
33
  // What the welcome says under the note: why the tier fell through when it did, the tier's leaning when it chose
32
34
  // And who is close when it did not, and the voice once one is set up
33
35
  const remarks = [
34
36
  loreFailure && localization.strings.lorePickUnanswered(loreFailure),
35
37
  loreResponse
36
- ? formatLoreChart(loreResponse, roster, localization)
38
+ ? formatLoreChart(loreRates, localization)
37
39
  : formatUpcomingBirthdays(roster, today, character.name, localization),
38
40
  readVoiceRemark(localization.strings),
39
41
  ];
40
42
  // The warm waits for no more than a synthesizer's bind
41
43
  await deliverWarmRequest(character.name, personaCard);
42
- console.log(getSessionStartOutput(card, remarks, readReplyLanguage() ?? language, language));
44
+ console.log(getSessionStartOutput(card, remarks, readReplyLanguage() ?? language, language, loreRates));
43
45
  }
@@ -0,0 +1,5 @@
1
+ // Node strips types and takes no `enum`, so the entry registers tsx before it loads its TypeScript
2
+ import { register } from "tsx/esm/api";
3
+
4
+ register();
5
+ await import("#scripts/react");
@@ -0,0 +1,34 @@
1
+ import { checkIsViewerMoment } from "#src/services/checkIsViewerMoment";
2
+ import { connectViewer } from "#src/services/connectViewer";
3
+ import { getViewerReaction } from "#src/services/getViewerReaction";
4
+ import { readViewerConfiguration } from "#src/services/readViewerConfiguration";
5
+ import { runHookCommand } from "#src/services/runHookCommand";
6
+ import { defineCommand } from "citty";
7
+
8
+ // The Notification and Stop hooks, each naming its moment on the command line: the model is sent what the reader
9
+ // Mapped to that moment, and nothing with no viewer on file, so a machine without one only pays this file check. A
10
+ // Hook leaves as soon as it has sent, so the connection is closed behind its last message
11
+ await runHookCommand(
12
+ defineCommand({
13
+ args: {
14
+ moment: {
15
+ default: "",
16
+ description: "The moment the hook reaches the model at",
17
+ required: false,
18
+ type: "positional",
19
+ },
20
+ },
21
+ meta: { description: "Send the viewer the reaction to the hook's moment", name: "react" },
22
+ run: async ({ args }) => {
23
+ const moment = checkIsViewerMoment(args.moment) ? args.moment : undefined;
24
+ const configuration = readViewerConfiguration();
25
+ if (!moment || !configuration) return;
26
+ const link = await connectViewer(configuration.address);
27
+ if (!link) return;
28
+ const reaction = getViewerReaction(moment, configuration.motions);
29
+ if (reaction.motion) link.motion(configuration.modelId, reaction.motion);
30
+ if (reaction.isExpressionCleared) link.expressionClear(configuration.modelId);
31
+ link.close();
32
+ },
33
+ }),
34
+ );
@@ -3,15 +3,14 @@ import { readInterfaceLanguage } from "#src/services/readInterfaceLanguage";
3
3
  import { readPersonaCard } from "#src/services/readPersonaCard";
4
4
  import { readRoster } from "#src/services/readRoster";
5
5
  import { readSpinner } from "#src/services/readSpinner";
6
- import { registerQuietExit } from "#src/services/registerQuietExit";
7
- import { defineCommand, runMain } from "citty";
6
+ import { runHookCommand } from "#src/services/runHookCommand";
7
+ import { defineCommand } from "citty";
8
8
 
9
9
  // The session's spinner for the hooks module, as JSON: its verbs and the character's lines under their name. Run off
10
10
  // The session start's own path, since the lines cost the data package or the wiki. The character arrives as the
11
11
  // English name, which is the identity, and the roster it is looked up in is the interface language's — so the label
12
12
  // Is the name that language spells them by
13
- registerQuietExit();
14
- await runMain(
13
+ await runHookCommand(
15
14
  defineCommand({
16
15
  args: {
17
16
  name: {
package/scripts/voice.ts CHANGED
@@ -2,35 +2,52 @@ import type { AudioPlayer } from "#src/models/AudioPlayer";
2
2
  import type { PcmClip } from "#src/models/PcmClip";
3
3
  import type { SpeakerTensors } from "#src/models/SpeakerTensors";
4
4
  import type { SpeechRequest } from "#src/models/SpeechRequest";
5
+ import type { ViewerCaption } from "#src/models/ViewerCaption";
5
6
 
6
7
  import { VoiceRequestType } from "#src/models/VoiceRequestType";
7
8
  import { VoiceStatus } from "#src/models/VoiceStatus";
9
+ import { connectViewer } from "#src/services/connectViewer";
8
10
  import {
9
11
  MAX_VOLUME,
10
12
  MESSAGE_DISPLAY_HOOK_TIMEOUT_MS,
11
13
  MODELS_DIRECTORY,
12
14
  RUNTIME_MANIFEST_PATH,
15
+ VIEWER_SOUND_FORM,
13
16
  VOICE_IDLE_TIMEOUT_MS,
14
17
  VOICE_STATUS_SEPARATOR,
15
18
  } from "#src/services/constants";
16
19
  import { createAudioPlayer } from "#src/services/createAudioPlayer";
17
20
  import { createClipDecoder } from "#src/services/createClipDecoder";
21
+ import { createReadingPlayer } from "#src/services/createReadingPlayer";
18
22
  import { createReplyQueue } from "#src/services/createReplyQueue";
23
+ import { createViewerOpener } from "#src/services/createViewerOpener";
24
+ import { createViewerPlayer } from "#src/services/createViewerPlayer";
19
25
  import { createVoiceSynthesizer } from "#src/services/createVoiceSynthesizer";
26
+ import { getViewerFrameColor } from "#src/services/getViewerFrameColor";
20
27
  import { getWavBytes } from "#src/services/getWavBytes";
21
28
  import { listenVoiceSocket } from "#src/services/listenVoiceSocket";
22
29
  import { parseVoiceRequest } from "#src/services/parseVoiceRequest";
23
30
  import { readReferenceClip } from "#src/services/readReferenceClip";
31
+ import { readViewerConfiguration } from "#src/services/readViewerConfiguration";
24
32
  import { readVoiceDevice } from "#src/services/readVoiceDevice";
25
33
  import { readVoiceRuntime } from "#src/services/readVoiceRuntime";
26
34
  import { writeVoiceDevice } from "#src/services/writeVoiceDevice";
27
35
  import { writeVoiceLog } from "#src/services/writeVoiceLog";
28
36
  import { createServer } from "node:net";
29
37
 
38
+ // The viewer a reading is shown on, when a `viewer` address is on file: connected at a reading's start and held until
39
+ // The synthesizer exits, since a tap on the model plays the last line again over the same connection
40
+ const viewerOpener = createViewerOpener({
41
+ connect: connectViewer,
42
+ createPlayer: createViewerPlayer,
43
+ readConfiguration: readViewerConfiguration,
44
+ });
30
45
  // The address is bound before the engine loads, so a second hook finds it taken rather than loading a second engine;
31
- // Every failure is logged, since the hooks stay silent on its behalf
46
+ // Every failure is logged, since the hooks stay silent on its behalf. The viewer is disposed too, so its last clip
47
+ // Does not outlive the process
32
48
  const exit = (message: string) => {
33
49
  writeVoiceLog(message);
50
+ viewerOpener.dispose();
34
51
  process.exit(1);
35
52
  };
36
53
  process.on("uncaughtException", (error) => {
@@ -45,6 +62,7 @@ if (!(await listenVoiceSocket(server))) process.exit(0);
45
62
  // Nothing between the bind and the connection handler below yields to the event loop: a hook connecting in such a gap
46
63
  // Is accepted by nobody and waits for an answer that never comes, so every load is started here and awaited later
47
64
  const idleTimer = setTimeout(() => {
65
+ viewerOpener.dispose();
48
66
  process.exit(0);
49
67
  }, VOICE_IDLE_TIMEOUT_MS);
50
68
  const runtime = readVoiceRuntime(RUNTIME_MANIFEST_PATH);
@@ -84,39 +102,40 @@ const speak = async (request: SpeechRequest, player: AudioPlayer | undefined, ch
84
102
  }
85
103
 
86
104
  const gain = request.volume / MAX_VOLUME;
87
- const play = async ({ play: playAudio }: AudioPlayer, clip: PcmClip) => {
105
+ const color = getViewerFrameColor(request.name);
106
+ // The caption is the line and the clip's own length, which is what a viewer shows and paces the next clip by
107
+ const play = async ({ play: playAudio }: AudioPlayer, clip: PcmClip, line: string) => {
88
108
  const audio = getWavBytes({ ...clip, samples: clip.samples.map((sample) => sample * gain) });
89
- const playerFailure = await playAudio(audio);
109
+ const caption: ViewerCaption = {
110
+ color,
111
+ durationMs: Math.round((clip.samples.length / clip.sampleRate) * 1000),
112
+ text: line,
113
+ };
114
+ const playerFailure = await playAudio(audio, caption);
90
115
  if (playerFailure) writeVoiceLog(`the player did not play: ${playerFailure}`);
91
116
  };
92
117
  let playback: Promise<void> = Promise.resolve();
93
- for (const [lineIndex, line] of request.lines.entries()) {
94
- let isSpoken = false;
95
- // Each chunk plays as it is vocoded, and a line that stops short is cut where it stood; leaving this loop early
96
- // Ends the generation behind it
97
- const label = `reply ${request.messageId}, line ${lineIndex + 1}`;
118
+ for (const line of request.lines) {
98
119
  // oxlint-disable-next-line no-await-in-loop -- One device: lines synthesize and play back one after another
99
- for await (const clip of synthesizer.streamSpeech(line, speaker, label, checkIsSuperseded)) {
100
- isSpoken = true;
101
- if (synthesizer.device !== settledDevice) {
102
- settledDevice = synthesizer.device;
103
- writeVoiceDevice(settledDevice);
104
- }
105
-
106
- if (!player) continue;
107
-
108
- // oxlint-disable-next-line no-await-in-loop -- One device: chunks play back one after another
120
+ const clip = await synthesizer.synthesize(line, speaker);
121
+ if (!clip) {
122
+ // oxlint-disable-next-line no-await-in-loop -- The reading ends at a line the engine could not speak
109
123
  await playback;
110
- if (checkIsSuperseded()) return VoiceStatus.Superseded;
111
-
112
- playback = play(player, clip);
124
+ return VoiceStatus.Error;
113
125
  }
114
126
 
115
- if (!isSpoken) {
116
- // oxlint-disable-next-line no-await-in-loop -- One device: lines synthesize and play back one after another
117
- await playback;
118
- return VoiceStatus.Error;
127
+ if (synthesizer.device !== settledDevice) {
128
+ settledDevice = synthesizer.device;
129
+ writeVoiceDevice(settledDevice);
119
130
  }
131
+
132
+ if (!player) continue;
133
+
134
+ // oxlint-disable-next-line no-await-in-loop -- One device: a line plays once the one before it has
135
+ await playback;
136
+ if (checkIsSuperseded()) return VoiceStatus.Superseded;
137
+
138
+ playback = play(player, clip, line);
120
139
  }
121
140
 
122
141
  await playback;
@@ -126,8 +145,13 @@ const speak = async (request: SpeechRequest, player: AudioPlayer | undefined, ch
126
145
  // In front of the first sound. A request that throws is dropped and keeps the engine
127
146
  const queue = createReplyQueue(async (request: SpeechRequest, checkIsSuperseded: () => boolean) => {
128
147
  const isSpoken = request.type === VoiceRequestType.Speak && request.lines.length > 0;
129
- const player = isSpoken ? createAudioPlayer() : undefined;
148
+ // A viewer on file carries the reading, with the stock player behind it; with none, the reading is the stock player's
149
+ const viewer = isSpoken ? await viewerOpener.open() : undefined;
150
+ let player: AudioPlayer | undefined;
151
+ if (isSpoken)
152
+ player = viewer ? createReadingPlayer(viewer, createAudioPlayer, VIEWER_SOUND_FORM) : createAudioPlayer();
130
153
  const [outcome] = await Promise.allSettled([speak(request, player, checkIsSuperseded)]);
154
+ if (outcome?.status === "fulfilled" && outcome.value === VoiceStatus.Superseded) player?.stop?.();
131
155
  await player?.close();
132
156
  if (outcome?.status === "fulfilled") return outcome.value;
133
157
 
@@ -141,6 +165,7 @@ const handleLine = (line: string): Promise<VoiceStatus> => {
141
165
  idleTimer.refresh();
142
166
  if (request.type === VoiceRequestType.Stop) {
143
167
  setImmediate(() => {
168
+ viewerOpener.dispose();
144
169
  process.exit(0);
145
170
  });
146
171
  return Promise.resolve(VoiceStatus.Ok);
@@ -69,7 +69,7 @@ The value is the line's file stem on the community wiki — the title after `VO_
69
69
  pnpm ai:voice-match --check
70
70
  ```
71
71
 
72
- The full reasoning — how a reference is chosen and what its likeness number is worth — is the [reference selection](https://esposter.com/docs/infra/claude-interface/reference-selection) page's. **The generated values are measured, not heard**; correcting one by ear is a welcome edit, and the card is where that correction goes — never the generated map, which the next run overwrites. A session tempted to re-derive a reference by taste runs the measurement instead.
72
+ The full reasoning — how a reference is chosen and what its likeness number is worth — is the [reference selection](https://github.com/Esposter/Esposter/blob/main/openspec/specs/infra/claude-interface/reference-selection/design.md) page's. **The generated values are measured, not heard**; correcting one by ear is a welcome edit, and the card is where that correction goes — never the generated map, which the next run overwrites. A session tempted to re-derive a reference by taste runs the measurement instead.
73
73
 
74
74
  ## The spinner verbs
75
75