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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +35 -34
- package/hooks/hooks.json +9 -1
- package/mod/VerbDescriptionMap.ts +3 -1
- package/package.json +5 -7
- package/scripts/pick.ts +4 -2
- package/scripts/react.mjs +5 -0
- package/scripts/react.ts +34 -0
- package/scripts/spinner.ts +3 -4
- package/scripts/voice.ts +51 -26
- package/skills/genshin-author/SKILL.md +1 -1
- package/src/generated/characterLines/ChineseSimplified.json +993 -15
- package/src/generated/characterLines/ChineseTraditional.json +993 -15
- package/src/generated/characterLines/English.json +991 -13
- package/src/generated/characterLines/French.json +990 -12
- package/src/generated/characterLines/German.json +993 -15
- package/src/generated/characterLines/Indonesian.json +996 -18
- package/src/generated/characterLines/Italian.json +991 -13
- package/src/generated/characterLines/Japanese.json +998 -20
- package/src/generated/characterLines/Korean.json +994 -16
- package/src/generated/characterLines/Portuguese.json +991 -13
- package/src/generated/characterLines/Russian.json +993 -15
- package/src/generated/characterLines/Spanish.json +993 -15
- package/src/generated/characterLines/Thai.json +994 -16
- package/src/generated/characterLines/Turkish.json +992 -14
- package/src/generated/characterLines/Vietnamese.json +993 -15
- package/src/generated/genshinText/generated/text/ChineseSimplified.json +283 -24
- package/src/generated/genshinText/generated/text/ChineseTraditional.json +283 -24
- package/src/generated/genshinText/generated/text/English.json +283 -24
- package/src/generated/genshinText/generated/text/French.json +283 -24
- package/src/generated/genshinText/generated/text/German.json +283 -24
- package/src/generated/genshinText/generated/text/Indonesian.json +283 -24
- package/src/generated/genshinText/generated/text/Italian.json +283 -24
- package/src/generated/genshinText/generated/text/Japanese.json +283 -24
- package/src/generated/genshinText/generated/text/Korean.json +283 -24
- package/src/generated/genshinText/generated/text/Portuguese.json +283 -24
- package/src/generated/genshinText/generated/text/Russian.json +283 -24
- package/src/generated/genshinText/generated/text/Spanish.json +284 -25
- package/src/generated/genshinText/generated/text/Thai.json +283 -24
- package/src/generated/genshinText/generated/text/Turkish.json +283 -24
- package/src/generated/genshinText/generated/text/Vietnamese.json +283 -24
- package/src/generated/genshinText/models/GameTextKey.ts +359 -9
- package/src/generated/genshinText/services/ElementColorMap.ts +11 -0
- package/src/localizations/chineseSimplified.ts +10 -0
- package/src/localizations/chineseTraditional.ts +10 -0
- package/src/localizations/english.ts +12 -1
- package/src/localizations/french.ts +12 -0
- package/src/localizations/german.ts +12 -0
- package/src/localizations/indonesian.ts +11 -0
- package/src/localizations/italian.ts +12 -0
- package/src/localizations/japanese.ts +11 -0
- package/src/localizations/korean.ts +10 -0
- package/src/localizations/portuguese.ts +12 -0
- package/src/localizations/russian.ts +11 -0
- package/src/localizations/spanish.ts +12 -0
- package/src/localizations/thai.ts +10 -0
- package/src/localizations/turkish.ts +12 -0
- package/src/localizations/vietnamese.ts +11 -0
- package/src/models/AudioPlayer.ts +7 -3
- package/src/models/Card.ts +4 -0
- package/src/models/Character.ts +2 -0
- package/src/models/GenshinVerb.ts +1 -0
- package/src/models/LocalizationStrings.ts +10 -0
- package/src/models/LoreRate.ts +7 -0
- package/src/models/ViewerCaption.ts +7 -0
- package/src/models/ViewerConfiguration.ts +9 -0
- package/src/models/ViewerEvent.ts +4 -0
- package/src/models/ViewerLink.ts +11 -0
- package/src/models/ViewerMessage.ts +10 -0
- package/src/models/ViewerMoment.ts +8 -0
- package/src/models/ViewerSoundForm.ts +8 -0
- package/src/models/VoiceSynthesizer.ts +1 -11
- package/src/services/checkIsAudible.ts +5 -0
- package/src/services/checkIsModelId.ts +2 -0
- package/src/services/checkIsSpeech.ts +4 -3
- package/src/services/checkIsViewerAddress.ts +5 -0
- package/src/services/checkIsViewerMoment.ts +9 -0
- package/src/services/cli/commands/genshinCommand.ts +2 -0
- package/src/services/cli/commands/viewerCommand.ts +78 -0
- package/src/services/connectViewer.ts +152 -0
- package/src/services/constants.ts +34 -11
- package/src/services/createReadingPlayer.ts +35 -0
- package/src/services/createViewerOpener.ts +39 -0
- package/src/services/createViewerPlayer.ts +80 -0
- package/src/services/createVoiceSynthesizer.ts +0 -147
- package/src/services/formatLoreChart.ts +11 -24
- package/src/services/getCard.ts +3 -1
- package/src/services/getLoreRates.ts +24 -0
- package/src/services/getNameplateColor.ts +3 -4
- package/src/services/getRosterCachePath.ts +10 -2
- package/src/services/getSessionStartOutput.ts +21 -4
- package/src/services/getViewerFrameColor.ts +8 -0
- package/src/services/getViewerReaction.ts +16 -0
- package/src/services/readMotions.ts +15 -0
- package/src/services/readRoster.ts +1 -0
- package/src/services/readViewerConfiguration.ts +21 -0
- package/src/services/resolveSessionCharacter.ts +2 -2
- package/src/services/runHookCommand.ts +19 -0
- package/src/services/setViewer.ts +27 -0
- package/src/services/writeViewerConfiguration.ts +8 -0
- package/src/services/ElementColorMap.ts +0 -12
- package/src/services/createChunkSplicer.ts +0 -47
- 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://
|
|
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://
|
|
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://
|
|
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
|
|
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
|
|
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://
|
|
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://
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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.
|
|
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://
|
|
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.
|
|
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.
|
|
64
|
+
"node": ">=26.12.0"
|
|
67
65
|
},
|
|
68
|
-
"gitHead": "
|
|
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(
|
|
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
|
}
|
package/scripts/react.ts
ADDED
|
@@ -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
|
+
);
|
package/scripts/spinner.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
import { defineCommand
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
playback = play(player, clip);
|
|
124
|
+
return VoiceStatus.Error;
|
|
113
125
|
}
|
|
114
126
|
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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://
|
|
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
|
|