@nodaro/prompts 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/index.cjs +5453 -4271
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1221 -63
  4. package/dist/index.d.ts +1221 -63
  5. package/dist/index.js +5366 -4273
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/b7-person-pack-e2e.test.ts +37 -0
  9. package/src/__tests__/catalog-funnel-ratchet.test.ts +97 -0
  10. package/src/__tests__/catalog-packs.test.ts +130 -0
  11. package/src/__tests__/catalog-sidecar-coverage.test.ts +27 -0
  12. package/src/__tests__/catalog-terms.test.ts +162 -0
  13. package/src/__tests__/character-default-role.test.ts +3 -2
  14. package/src/__tests__/content-free-contract.test.ts +45 -0
  15. package/src/__tests__/dod-replace-pack-acceptance.test.ts +41 -0
  16. package/src/__tests__/fixtures/parameter-hint-golden.json +2959 -0
  17. package/src/__tests__/fixtures/person-sector-pack.ts +27 -0
  18. package/src/__tests__/i18n-entry-completeness.test.ts +5 -2
  19. package/src/__tests__/parameter-hint-mode.test.ts +385 -0
  20. package/src/__tests__/parameter-prompt-hint-pack-fallback.test.ts +25 -0
  21. package/src/__tests__/person-packs.test.ts +166 -0
  22. package/src/__tests__/project-all-catalogs.test.ts +26 -0
  23. package/src/__tests__/prompt-builder.test.ts +53 -0
  24. package/src/__tests__/registered-catalogs-funnel.test.ts +30 -0
  25. package/src/__tests__/registered-catalogs-guard.test.ts +11 -0
  26. package/src/__tests__/term.test.ts +104 -0
  27. package/src/__tests__/transitions.test.ts +8 -5
  28. package/src/__tests__/upstream-immutability.test.ts +23 -0
  29. package/src/action-fx.ts +67 -17
  30. package/src/aesthetic.ts +54 -6
  31. package/src/atmosphere.ts +64 -23
  32. package/src/backdrop.ts +44 -30
  33. package/src/camera-format.ts +33 -11
  34. package/src/camera-motions.ts +89 -0
  35. package/src/catalog-packs.ts +125 -0
  36. package/src/catalog-sidecar-coverage.ts +36 -0
  37. package/src/character-fx.ts +112 -39
  38. package/src/color-look.ts +44 -27
  39. package/src/composition-effects.ts +21 -7
  40. package/src/era.ts +24 -0
  41. package/src/exposure-settings.ts +76 -18
  42. package/src/framing.ts +100 -0
  43. package/src/held-prop.ts +125 -63
  44. package/src/identity-lock.ts +12 -5
  45. package/src/image-reference-doctrine.ts +55 -0
  46. package/src/index.ts +6 -0
  47. package/src/instrumentation.ts +148 -57
  48. package/src/lens.ts +31 -15
  49. package/src/lighting.ts +120 -59
  50. package/src/loop-subject.ts +27 -1
  51. package/src/materials.ts +123 -69
  52. package/src/mood.ts +123 -51
  53. package/src/music-genre.ts +171 -67
  54. package/src/music-mood.ts +85 -17
  55. package/src/parameter-prompt-hint.ts +168 -56
  56. package/src/person-packs.ts +182 -0
  57. package/src/person.ts +506 -408
  58. package/src/photo-genre.ts +37 -22
  59. package/src/photographer.ts +138 -1
  60. package/src/picker-catalogs.ts +86 -40
  61. package/src/picker-wiring.ts +1 -1
  62. package/src/pose.ts +105 -40
  63. package/src/post-process-effects.ts +51 -8
  64. package/src/prompt-builder.ts +16 -1
  65. package/src/provider-prompt-doctrine.ts +1 -2
  66. package/src/render-quality.ts +25 -7
  67. package/src/setting.ts +30 -14
  68. package/src/style-presets.ts +1 -1
  69. package/src/style.ts +33 -15
  70. package/src/styling.ts +155 -94
  71. package/src/surround-fill.ts +67 -0
  72. package/src/temporal.ts +80 -18
  73. package/src/term.ts +155 -0
  74. package/src/transitions.ts +73 -26
  75. package/src/voice-character.ts +190 -90
  76. package/src/voice-delivery.ts +83 -12
@@ -8,12 +8,15 @@
8
8
  */
9
9
 
10
10
  import { pickIds } from "@nodaro/shared"
11
+ import { resolveTerm, type PickerHintMode } from "./term.js"
11
12
 
12
13
  export interface VoiceCharacterEntry {
13
14
  readonly id: string
14
15
  readonly label: string
15
16
  readonly description: string
16
17
  readonly promptHint: string
18
+ /** Optional authored compact term (see `term.ts` for the convention). */
19
+ readonly term?: string
17
20
  }
18
21
 
19
22
  export const VOICE_AGES: ReadonlyArray<VoiceCharacterEntry> = [
@@ -71,83 +74,83 @@ export const VOICE_LANGUAGES: ReadonlyArray<VoiceCharacterEntry> = [
71
74
 
72
75
  export const VOICE_ACCENTS: ReadonlyArray<VoiceCharacterEntry> = [
73
76
  // North America
74
- { id: "general-american", label: "General American", description: "Neutral US accent", promptHint: "general American" },
75
- { id: "southern-us", label: "Southern US", description: "US Southern drawl", promptHint: "Southern US" },
76
- { id: "new-york", label: "New York", description: "NY metro accent", promptHint: "New York" },
77
- { id: "boston", label: "Boston", description: "Boston / Mass accent", promptHint: "Boston" },
78
- { id: "midwestern-us", label: "Midwestern", description: "US heartland", promptHint: "Midwestern American" },
79
- { id: "chicago", label: "Chicago", description: "Upper-Midwest urban", promptHint: "Chicago" },
80
- { id: "appalachian", label: "Appalachian", description: "Mountain South", promptHint: "Appalachian" },
81
- { id: "canadian", label: "Canadian", description: "Canada English", promptHint: "Canadian" },
77
+ { id: "general-american", label: "General American", description: "Neutral US accent", promptHint: "general American", term: "general american accent" },
78
+ { id: "southern-us", label: "Southern US", description: "US Southern drawl", promptHint: "Southern US", term: "southern american accent" },
79
+ { id: "new-york", label: "New York", description: "NY metro accent", promptHint: "New York", term: "new york accent" },
80
+ { id: "boston", label: "Boston", description: "Boston / Mass accent", promptHint: "Boston", term: "boston accent" },
81
+ { id: "midwestern-us", label: "Midwestern", description: "US heartland", promptHint: "Midwestern American", term: "midwestern american accent" },
82
+ { id: "chicago", label: "Chicago", description: "Upper-Midwest urban", promptHint: "Chicago", term: "chicago accent" },
83
+ { id: "appalachian", label: "Appalachian", description: "Mountain South", promptHint: "Appalachian", term: "appalachian accent" },
84
+ { id: "canadian", label: "Canadian", description: "Canada English", promptHint: "Canadian", term: "canadian accent" },
82
85
  // British Isles
83
- { id: "british-rp", label: "British RP", description: "Received Pronunciation", promptHint: "British RP" },
84
- { id: "cockney", label: "Cockney", description: "London working-class", promptHint: "Cockney" },
85
- { id: "estuary-english", label: "Estuary", description: "South-East England", promptHint: "Estuary English" },
86
- { id: "northern-english", label: "Northern English", description: "Manchester / Yorkshire", promptHint: "Northern English" },
87
- { id: "scouse", label: "Scouse", description: "Liverpool", promptHint: "Scouse" },
88
- { id: "geordie", label: "Geordie", description: "Newcastle", promptHint: "Geordie" },
89
- { id: "scottish", label: "Scottish", description: "Scotland", promptHint: "Scottish" },
90
- { id: "irish", label: "Irish", description: "Ireland", promptHint: "Irish" },
91
- { id: "welsh", label: "Welsh", description: "Wales", promptHint: "Welsh" },
86
+ { id: "british-rp", label: "British RP", description: "Received Pronunciation", promptHint: "British RP", term: "received pronunciation accent" },
87
+ { id: "cockney", label: "Cockney", description: "London working-class", promptHint: "Cockney", term: "cockney accent" },
88
+ { id: "estuary-english", label: "Estuary", description: "South-East England", promptHint: "Estuary English", term: "estuary english accent" },
89
+ { id: "northern-english", label: "Northern English", description: "Manchester / Yorkshire", promptHint: "Northern English", term: "northern english accent" },
90
+ { id: "scouse", label: "Scouse", description: "Liverpool", promptHint: "Scouse", term: "scouse accent" },
91
+ { id: "geordie", label: "Geordie", description: "Newcastle", promptHint: "Geordie", term: "geordie accent" },
92
+ { id: "scottish", label: "Scottish", description: "Scotland", promptHint: "Scottish", term: "scottish accent" },
93
+ { id: "irish", label: "Irish", description: "Ireland", promptHint: "Irish", term: "irish accent" },
94
+ { id: "welsh", label: "Welsh", description: "Wales", promptHint: "Welsh", term: "welsh accent" },
92
95
  // English-speaking world
93
- { id: "australian", label: "Australian", description: "Australia", promptHint: "Australian" },
94
- { id: "new-zealand", label: "New Zealand", description: "NZ Kiwi", promptHint: "New Zealand" },
95
- { id: "south-african", label: "South African", description: "South Africa", promptHint: "South African" },
96
- { id: "indian-english", label: "Indian English", description: "South Asian English", promptHint: "Indian English" },
97
- { id: "caribbean", label: "Caribbean", description: "Caribbean English", promptHint: "Caribbean" },
98
- { id: "jamaican", label: "Jamaican", description: "Jamaican Patois", promptHint: "Jamaican" },
96
+ { id: "australian", label: "Australian", description: "Australia", promptHint: "Australian", term: "australian accent" },
97
+ { id: "new-zealand", label: "New Zealand", description: "NZ Kiwi", promptHint: "New Zealand", term: "new zealand accent" },
98
+ { id: "south-african", label: "South African", description: "South Africa", promptHint: "South African", term: "south african accent" },
99
+ { id: "indian-english", label: "Indian English", description: "South Asian English", promptHint: "Indian English", term: "indian english accent" },
100
+ { id: "caribbean", label: "Caribbean", description: "Caribbean English", promptHint: "Caribbean", term: "caribbean accent" },
101
+ { id: "jamaican", label: "Jamaican", description: "Jamaican Patois", promptHint: "Jamaican", term: "jamaican accent" },
99
102
  // Continental European-accented English
100
- { id: "french-accented", label: "French", description: "French-accented English", promptHint: "French-accented" },
101
- { id: "italian-accented", label: "Italian", description: "Italian-accented English", promptHint: "Italian-accented" },
102
- { id: "german-accented", label: "German", description: "German-accented English", promptHint: "German-accented" },
103
- { id: "dutch-accented", label: "Dutch", description: "Dutch-accented English", promptHint: "Dutch-accented" },
104
- { id: "russian-accented", label: "Russian", description: "Russian-accented English", promptHint: "Russian-accented" },
105
- { id: "polish-accented", label: "Polish", description: "Polish-accented English", promptHint: "Polish-accented" },
106
- { id: "spanish-accented", label: "Spanish", description: "Spanish-accented English", promptHint: "Spanish-accented" },
107
- { id: "portuguese-accented", label: "Portuguese", description: "Portuguese / Brazilian", promptHint: "Portuguese-accented" },
108
- { id: "scandinavian-accented", label: "Scandinavian", description: "Nordic-accented English", promptHint: "Scandinavian-accented" },
103
+ { id: "french-accented", label: "French", description: "French-accented English", promptHint: "French-accented", term: "french accent" },
104
+ { id: "italian-accented", label: "Italian", description: "Italian-accented English", promptHint: "Italian-accented", term: "italian accent" },
105
+ { id: "german-accented", label: "German", description: "German-accented English", promptHint: "German-accented", term: "german accent" },
106
+ { id: "dutch-accented", label: "Dutch", description: "Dutch-accented English", promptHint: "Dutch-accented", term: "dutch accent" },
107
+ { id: "russian-accented", label: "Russian", description: "Russian-accented English", promptHint: "Russian-accented", term: "russian accent" },
108
+ { id: "polish-accented", label: "Polish", description: "Polish-accented English", promptHint: "Polish-accented", term: "polish accent" },
109
+ { id: "spanish-accented", label: "Spanish", description: "Spanish-accented English", promptHint: "Spanish-accented", term: "spanish accent" },
110
+ { id: "portuguese-accented", label: "Portuguese", description: "Portuguese / Brazilian", promptHint: "Portuguese-accented", term: "portuguese accent" },
111
+ { id: "scandinavian-accented", label: "Scandinavian", description: "Nordic-accented English", promptHint: "Scandinavian-accented", term: "scandinavian accent" },
109
112
  // Latin America
110
- { id: "mexican-accented", label: "Mexican", description: "Mexican-accented English", promptHint: "Mexican-accented" },
111
- { id: "argentinian-accented", label: "Argentinian", description: "Río de la Plata", promptHint: "Argentinian-accented" },
113
+ { id: "mexican-accented", label: "Mexican", description: "Mexican-accented English", promptHint: "Mexican-accented", term: "mexican accent" },
114
+ { id: "argentinian-accented", label: "Argentinian", description: "Río de la Plata", promptHint: "Argentinian-accented", term: "argentinian accent" },
112
115
  // Asia / Middle East
113
- { id: "japanese-accented", label: "Japanese", description: "Japanese-accented English", promptHint: "Japanese-accented" },
114
- { id: "korean-accented", label: "Korean", description: "Korean-accented English", promptHint: "Korean-accented" },
115
- { id: "chinese-accented", label: "Chinese", description: "Mandarin-accented", promptHint: "Chinese-accented" },
116
- { id: "filipino-accented", label: "Filipino", description: "Filipino-accented English", promptHint: "Filipino-accented" },
117
- { id: "arabic-accented", label: "Arabic", description: "Arabic-accented English", promptHint: "Arabic-accented" },
118
- { id: "hebrew-accented", label: "Hebrew", description: "Israeli-accented English", promptHint: "Hebrew-accented" },
119
- { id: "turkish-accented", label: "Turkish", description: "Turkish-accented English", promptHint: "Turkish-accented" },
120
- { id: "persian-accented", label: "Persian", description: "Persian-accented English", promptHint: "Persian-accented" },
116
+ { id: "japanese-accented", label: "Japanese", description: "Japanese-accented English", promptHint: "Japanese-accented", term: "japanese accent" },
117
+ { id: "korean-accented", label: "Korean", description: "Korean-accented English", promptHint: "Korean-accented", term: "korean accent" },
118
+ { id: "chinese-accented", label: "Chinese", description: "Mandarin-accented", promptHint: "Chinese-accented", term: "chinese accent" },
119
+ { id: "filipino-accented", label: "Filipino", description: "Filipino-accented English", promptHint: "Filipino-accented", term: "filipino accent" },
120
+ { id: "arabic-accented", label: "Arabic", description: "Arabic-accented English", promptHint: "Arabic-accented", term: "arabic accent" },
121
+ { id: "hebrew-accented", label: "Hebrew", description: "Israeli-accented English", promptHint: "Hebrew-accented", term: "hebrew accent" },
122
+ { id: "turkish-accented", label: "Turkish", description: "Turkish-accented English", promptHint: "Turkish-accented", term: "turkish accent" },
123
+ { id: "persian-accented", label: "Persian", description: "Persian-accented English", promptHint: "Persian-accented", term: "persian accent" },
121
124
  // Africa
122
- { id: "nigerian-accented", label: "Nigerian", description: "Nigerian English", promptHint: "Nigerian-accented" },
125
+ { id: "nigerian-accented", label: "Nigerian", description: "Nigerian English", promptHint: "Nigerian-accented", term: "nigerian accent" },
123
126
  // General
124
- { id: "neutral-international", label: "Neutral International", description: "Region-agnostic", promptHint: "neutral international" },
125
- { id: "transatlantic", label: "Transatlantic", description: "Mid-Atlantic theatrical", promptHint: "transatlantic" },
127
+ { id: "neutral-international", label: "Neutral International", description: "Region-agnostic", promptHint: "neutral international", term: "neutral international accent" },
128
+ { id: "transatlantic", label: "Transatlantic", description: "Mid-Atlantic theatrical", promptHint: "transatlantic", term: "transatlantic accent" },
126
129
  ] as const
127
130
 
128
131
  export const VOICE_TIMBRES: ReadonlyArray<VoiceCharacterEntry> = [
129
- { id: "warm", label: "Warm", description: "Rich, inviting", promptHint: "warm" },
130
- { id: "smooth", label: "Smooth", description: "Clean, even", promptHint: "smooth" },
131
- { id: "silky", label: "Silky", description: "Soft, refined", promptHint: "silky" },
132
- { id: "velvety", label: "Velvety", description: "Lush, plush", promptHint: "velvety" },
133
- { id: "raspy", label: "Raspy", description: "Rough, textured", promptHint: "raspy" },
134
- { id: "gravelly", label: "Gravelly", description: "Deeply textured", promptHint: "gravelly" },
135
- { id: "rough", label: "Rough", description: "Coarse, weathered", promptHint: "rough" },
136
- { id: "husky", label: "Husky", description: "Throaty, hoarse", promptHint: "husky" },
137
- { id: "breathy", label: "Breathy", description: "Airy, intimate", promptHint: "breathy" },
138
- { id: "whispered", label: "Whispered", description: "Hushed, intimate", promptHint: "whispered" },
139
- { id: "nasal", label: "Nasal", description: "Resonates in nose", promptHint: "nasal" },
140
- { id: "twangy", label: "Twangy", description: "Sharp, regional", promptHint: "twangy" },
141
- { id: "deep", label: "Deep", description: "Low pitch range", promptHint: "deep" },
142
- { id: "booming", label: "Booming", description: "Resonant, large", promptHint: "booming" },
143
- { id: "high-pitched", label: "High-pitched", description: "Upper register", promptHint: "high-pitched" },
144
- { id: "squeaky", label: "Squeaky", description: "Thin, piercing", promptHint: "squeaky" },
145
- { id: "bright", label: "Bright", description: "Crisp, forward", promptHint: "bright" },
146
- { id: "dark", label: "Dark", description: "Heavy, somber", promptHint: "dark" },
147
- { id: "youthful", label: "Youthful", description: "Light, fresh", promptHint: "youthful" },
148
- { id: "authoritative",label: "Authoritative",description: "Commanding", promptHint: "authoritative" },
149
- { id: "sultry", label: "Sultry", description: "Sensual, smoky", promptHint: "sultry" },
150
- { id: "polished", label: "Polished", description: "Practiced, broadcast-ready", promptHint: "polished" },
132
+ { id: "warm", label: "Warm", description: "Rich, inviting", promptHint: "warm", term: "warm timbre" },
133
+ { id: "smooth", label: "Smooth", description: "Clean, even", promptHint: "smooth", term: "smooth timbre" },
134
+ { id: "silky", label: "Silky", description: "Soft, refined", promptHint: "silky", term: "silky timbre" },
135
+ { id: "velvety", label: "Velvety", description: "Lush, plush", promptHint: "velvety", term: "velvety timbre" },
136
+ { id: "raspy", label: "Raspy", description: "Rough, textured", promptHint: "raspy", term: "raspy timbre" },
137
+ { id: "gravelly", label: "Gravelly", description: "Deeply textured", promptHint: "gravelly", term: "gravelly timbre" },
138
+ { id: "rough", label: "Rough", description: "Coarse, weathered", promptHint: "rough", term: "rough timbre" },
139
+ { id: "husky", label: "Husky", description: "Throaty, hoarse", promptHint: "husky", term: "husky timbre" },
140
+ { id: "breathy", label: "Breathy", description: "Airy, intimate", promptHint: "breathy", term: "breathy timbre" },
141
+ { id: "whispered", label: "Whispered", description: "Hushed, intimate", promptHint: "whispered", term: "whispered timbre" },
142
+ { id: "nasal", label: "Nasal", description: "Resonates in nose", promptHint: "nasal", term: "nasal timbre" },
143
+ { id: "twangy", label: "Twangy", description: "Sharp, regional", promptHint: "twangy", term: "twangy timbre" },
144
+ { id: "deep", label: "Deep", description: "Low pitch range", promptHint: "deep", term: "deep timbre" },
145
+ { id: "booming", label: "Booming", description: "Resonant, large", promptHint: "booming", term: "booming timbre" },
146
+ { id: "high-pitched", label: "High-pitched", description: "Upper register", promptHint: "high-pitched", term: "high-pitched timbre" },
147
+ { id: "squeaky", label: "Squeaky", description: "Thin, piercing", promptHint: "squeaky", term: "squeaky timbre" },
148
+ { id: "bright", label: "Bright", description: "Crisp, forward", promptHint: "bright", term: "bright timbre" },
149
+ { id: "dark", label: "Dark", description: "Heavy, somber", promptHint: "dark", term: "dark timbre" },
150
+ { id: "youthful", label: "Youthful", description: "Light, fresh", promptHint: "youthful", term: "youthful timbre" },
151
+ { id: "authoritative",label: "Authoritative",description: "Commanding", promptHint: "authoritative", term: "authoritative timbre" },
152
+ { id: "sultry", label: "Sultry", description: "Sensual, smoky", promptHint: "sultry", term: "sultry timbre" },
153
+ { id: "polished", label: "Polished", description: "Practiced, broadcast-ready", promptHint: "polished", term: "polished timbre" },
151
154
  ] as const
152
155
 
153
156
  const AGE_BY_ID = new Map(VOICE_AGES.map((x) => [x.id, x]))
@@ -163,19 +166,42 @@ export function getVoiceAccent(id: string | undefined) { return id ? ACCENT_BY_I
163
166
  export function getVoiceTimbre(id: string | undefined) { return id ? TIMBRE_BY_ID.get(id) : undefined }
164
167
 
165
168
  /**
166
- * Compose a natural-language voice character clause.
167
- * Examples (depending on which sub-fields are set):
168
- * { age, gender, timbre, accent } → "middle-aged male voice with warm timbre and British RP accent"
169
- * { timbre } → "warm timbre"
170
- * { accent } → "British RP accent"
171
- * { age, gender } → "middle-aged male voice"
172
- * { language: ["english","spanish"] } → "English / Spanish voice"
173
- * { } → ""
169
+ * The COMPACT counterparts of the five sub-field lookups: the short
170
+ * professional term a consumer injects instead of the entry's hint fragment.
171
+ * Same lookup, same empty-string-on-miss behavior.
174
172
  *
175
- * `language` is multi-pick — multiple languages emit "English / Spanish"
176
- * for codeswitching / multilingual voices.
173
+ * Accent and timbre terms CARRY their dimension noun — "boston accent",
174
+ * "french accent", "warm timbre" — where the `promptHint` fragments are bare
175
+ * ("Boston", "French-accented", "warm") and only read as an accent or a
176
+ * timbre once `buildVoiceCharacterHints` appends the noun. The term has to
177
+ * stand on its own because a thin client injects one by itself (a picker
178
+ * chip), with no composer to append anything: a bare "french" there is
179
+ * indistinguishable from the LANGUAGE French sitting in the neighbouring
180
+ * dimension.
181
+ *
182
+ * Age, gender and language terms deliberately stay BARE — "male", "mature",
183
+ * "english". Do NOT "fix" them by authoring the noun into the data:
184
+ * `composeVoiceCharacter` appends " voice" to the age+gender group itself, so
185
+ * a "male voice" term would emit "...male voice voice".
177
186
  */
178
- export function buildVoiceCharacterHints(data: {
187
+ export function getVoiceAgeTerm(id: string | undefined | null): string {
188
+ return resolveTerm(getVoiceAge(id ?? undefined))
189
+ }
190
+ export function getVoiceGenderTerm(id: string | undefined | null): string {
191
+ return resolveTerm(getVoiceGender(id ?? undefined))
192
+ }
193
+ export function getVoiceLanguageTerm(id: string | undefined | null): string {
194
+ return resolveTerm(getVoiceLanguage(id ?? undefined))
195
+ }
196
+ export function getVoiceAccentTerm(id: string | undefined | null): string {
197
+ return resolveTerm(getVoiceAccent(id ?? undefined))
198
+ }
199
+ export function getVoiceTimbreTerm(id: string | undefined | null): string {
200
+ return resolveTerm(getVoiceTimbre(id ?? undefined))
201
+ }
202
+
203
+ /** The consumer fields a voice-character clause is composed from. */
204
+ type VoiceCharacterFieldSource = {
179
205
  readonly preText?: string
180
206
  readonly postText?: string
181
207
  readonly age?: string
@@ -183,26 +209,47 @@ export function buildVoiceCharacterHints(data: {
183
209
  readonly language?: string | ReadonlyArray<string>
184
210
  readonly accent?: string
185
211
  readonly timbre?: string
186
- }): string {
212
+ }
213
+
214
+ /**
215
+ * How one composition mode turns catalog entries into fragments.
216
+ *
217
+ * `of` picks the fragment (the long `promptHint`, or the compact `term`), and
218
+ * `trait` attaches the dimension noun: the bare hint fragments always need it
219
+ * ("warm" → "warm timbre"), while the compact mode appends it only when the
220
+ * fragment does not already end in it ("warm timbre" is passed through as
221
+ * authored, a term-less "bavarian" still becomes "bavarian accent").
222
+ */
223
+ interface VoiceCharacterMode {
224
+ readonly of: (entry: VoiceCharacterEntry | undefined) => string
225
+ readonly trait: (fragment: string, noun: "timbre" | "accent") => string
226
+ }
227
+
228
+ /**
229
+ * The shared composition both modes walk, so the verbose and compact clauses
230
+ * can never drift in shape — same field order, same "voice with X and Y"
231
+ * skeleton, same language / preText / postText handling.
232
+ */
233
+ function composeVoiceCharacter(data: VoiceCharacterFieldSource, mode: VoiceCharacterMode): string {
187
234
  const fragments: string[] = []
188
235
  const pre = typeof data.preText === "string" ? data.preText.trim() : ""
189
236
  if (pre) fragments.push(pre)
190
237
 
191
- const age = getVoiceAge(data.age)
192
- const gender = getVoiceGender(data.gender)
193
- const accent = getVoiceAccent(data.accent)
194
- const timbre = getVoiceTimbre(data.timbre)
238
+ const age = mode.of(getVoiceAge(data.age))
239
+ const gender = mode.of(getVoiceGender(data.gender))
240
+ const accent = mode.of(getVoiceAccent(data.accent))
241
+ const timbre = mode.of(getVoiceTimbre(data.timbre))
195
242
 
196
243
  const langIds = pickIds(data.language)
197
- const langHints = langIds
198
- .map((id) => getVoiceLanguage(id)?.promptHint)
199
- .filter((h): h is string => !!h)
200
- const langClause = langHints.join(" / ")
244
+ const langFragments = langIds
245
+ .map((id) => mode.of(getVoiceLanguage(id)))
246
+ .filter((h) => !!h)
247
+ const langClause = langFragments.join(" / ")
201
248
 
202
- const ageGender = [age?.promptHint, gender?.promptHint].filter(Boolean).join(" ")
249
+ const ageGender = [age, gender].filter(Boolean).join(" ")
203
250
  const traits: string[] = []
204
- if (timbre) traits.push(`${timbre.promptHint} timbre`)
205
- if (accent) traits.push(`${accent.promptHint} accent`)
251
+ if (timbre) traits.push(mode.trait(timbre, "timbre"))
252
+ if (accent) traits.push(mode.trait(accent, "accent"))
206
253
 
207
254
  let core = ""
208
255
  if (ageGender && traits.length > 0) {
@@ -226,6 +273,59 @@ export function buildVoiceCharacterHints(data: {
226
273
  return fragments.join(", ")
227
274
  }
228
275
 
276
+ /**
277
+ * Compose a natural-language voice character clause.
278
+ * Examples (depending on which sub-fields are set):
279
+ * { age, gender, timbre, accent } → "middle-aged male voice with warm timbre and British RP accent"
280
+ * { timbre } → "warm timbre"
281
+ * { accent } → "British RP accent"
282
+ * { age, gender } → "middle-aged male voice"
283
+ * { language: ["english","spanish"] } → "English / Spanish voice"
284
+ * { } → ""
285
+ *
286
+ * `language` is multi-pick — multiple languages emit "English / Spanish"
287
+ * for codeswitching / multilingual voices.
288
+ */
289
+ export function buildVoiceCharacterHints(data: VoiceCharacterFieldSource, mode: PickerHintMode = "full"): string {
290
+ if (mode === "compact") return buildVoiceCharacterTerms(data)
291
+ return composeVoiceCharacter(data, {
292
+ of: (entry) => entry?.promptHint ?? "",
293
+ trait: (fragment, noun) => `${fragment} ${noun}`,
294
+ })
295
+ }
296
+
297
+ /**
298
+ * Attach the dimension noun unless the fragment already ends with it.
299
+ *
300
+ * Every accent and timbre entry authors a noun-bearing term today ("warm
301
+ * timbre", "boston accent"), and this keeps that from being an ASSUMPTION the
302
+ * compact clause silently depends on: an entry added later with no `term`
303
+ * derives a bare "bavarian" from its label, and the noun is attached here
304
+ * rather than shipping a lone modifier no model can read as an accent.
305
+ */
306
+ function withDimensionNoun(fragment: string, noun: "timbre" | "accent"): string {
307
+ return fragment === noun || fragment.endsWith(` ${noun}`) ? fragment : `${fragment} ${noun}`
308
+ }
309
+
310
+ /**
311
+ * The COMPACT counterpart of `buildVoiceCharacterHints`: the same clause
312
+ * skeleton, built from each selection's short professional term instead of its
313
+ * hint fragment. The dimension noun is attached idempotently — an authored
314
+ * term already carries it ("warm timbre", "received pronunciation accent") and
315
+ * passes through untouched.
316
+ * Examples:
317
+ * { age, gender, timbre, accent } → "middle-aged male voice with warm timbre and received pronunciation accent"
318
+ * { timbre } → "warm timbre"
319
+ * { language: ["english","spanish"] } → "english / spanish voice"
320
+ * { } → ""
321
+ */
322
+ export function buildVoiceCharacterTerms(data: VoiceCharacterFieldSource): string {
323
+ return composeVoiceCharacter(data, {
324
+ of: (entry) => resolveTerm(entry),
325
+ trait: withDimensionNoun,
326
+ })
327
+ }
328
+
229
329
  export const VOICE_CHARACTER_DEFAULT_DATA: {
230
330
  preText?: string
231
331
  postText?: string
@@ -3,17 +3,27 @@
3
3
  * Voice Design's voiceDescription via the Sound aggregator.
4
4
  */
5
5
 
6
+ import { resolveTerm, type PickerHintMode } from "./term.js"
7
+
6
8
  export interface VoiceDeliveryEntry {
7
9
  readonly id: string
8
10
  readonly label: string
9
11
  readonly description: string
10
12
  readonly promptHint: string
13
+ /**
14
+ * The COMPACT professional term this entry injects (see `./term.js`).
15
+ * Authored only where the lowercased label is not what a voice director
16
+ * would actually write — "drawled" not "drawl", "robotic" not "robot",
17
+ * "nature documentary narrator" not "nature narrator". Everywhere else the
18
+ * derived label already IS the term and nothing is stored.
19
+ */
20
+ readonly term?: string
11
21
  }
12
22
 
13
23
  export const VOICE_PACES: ReadonlyArray<VoiceDeliveryEntry> = [
14
24
  { id: "very-slow", label: "Very Slow", description: "Deliberate, ponderous", promptHint: "very slow" },
15
25
  { id: "slow", label: "Slow", description: "Measured", promptHint: "slow" },
16
- { id: "drawl", label: "Drawl", description: "Drawn-out, lazy", promptHint: "drawled" },
26
+ { id: "drawl", label: "Drawl", description: "Drawn-out, lazy", promptHint: "drawled", term: "drawled" },
17
27
  { id: "measured", label: "Measured", description: "Steady, intentional", promptHint: "measured" },
18
28
  { id: "moderate", label: "Moderate", description: "Conversational pace", promptHint: "moderate" },
19
29
  { id: "punchy", label: "Punchy", description: "Clipped, emphatic", promptHint: "punchy" },
@@ -55,7 +65,7 @@ export const VOICE_ARCHETYPES: ReadonlyArray<VoiceDeliveryEntry> = [
55
65
  { id: "newscaster", label: "Newscaster", description: "Authoritative news delivery", promptHint: "newscaster" },
56
66
  { id: "anchor", label: "TV Anchor", description: "Polished broadcast", promptHint: "TV anchor" },
57
67
  { id: "documentary-narrator", label: "Documentary Narrator", description: "Informative, measured", promptHint: "documentary narrator" },
58
- { id: "nature-narrator", label: "Nature Narrator", description: "Soft-spoken Attenborough-style", promptHint: "nature documentary narrator" },
68
+ { id: "nature-narrator", label: "Nature Narrator", description: "Soft-spoken Attenborough-style", promptHint: "nature documentary narrator", term: "nature documentary narrator" },
59
69
  { id: "fairy-tale-narrator", label: "Fairy-Tale Narrator", description: "Storyteller for children", promptHint: "fairy-tale narrator" },
60
70
  { id: "audiobook-narrator", label: "Audiobook Narrator", description: "Long-form fiction reading", promptHint: "audiobook narrator" },
61
71
  { id: "podcaster", label: "Podcaster", description: "Conversational", promptHint: "podcaster" },
@@ -74,16 +84,16 @@ export const VOICE_ARCHETYPES: ReadonlyArray<VoiceDeliveryEntry> = [
74
84
  { id: "drag-queen", label: "Drag Queen", description: "Theatrical, fierce", promptHint: "drag queen" },
75
85
  // Character archetypes
76
86
  { id: "villain", label: "Villain", description: "Sinister, scheming", promptHint: "villain" },
77
- { id: "hero", label: "Hero", description: "Earnest, courageous", promptHint: "heroic" },
87
+ { id: "hero", label: "Hero", description: "Earnest, courageous", promptHint: "heroic", term: "heroic" },
78
88
  { id: "mentor", label: "Mentor", description: "Wise, guiding", promptHint: "mentor" },
79
- { id: "comedian", label: "Comedian", description: "Comedic timing", promptHint: "comedic" },
89
+ { id: "comedian", label: "Comedian", description: "Comedic timing", promptHint: "comedic", term: "comedic" },
80
90
  { id: "stand-up-comic", label: "Stand-up Comic", description: "Punchline-driven", promptHint: "stand-up comic" },
81
91
  { id: "drill-sergeant", label: "Drill Sergeant", description: "Barking commands", promptHint: "drill sergeant" },
82
92
  { id: "meditation-guide", label: "Meditation Guide", description: "Soft, slow guidance", promptHint: "meditation guide" },
83
93
  { id: "yoga-instructor", label: "Yoga Instructor", description: "Calm, grounding cues", promptHint: "yoga instructor" },
84
94
  { id: "noir-detective", label: "Noir Detective", description: "Hard-boiled, brooding", promptHint: "noir detective" },
85
95
  { id: "wizard", label: "Wizard", description: "Mystic, ancient", promptHint: "wizard" },
86
- { id: "robot", label: "Robot", description: "Synthetic, mechanical", promptHint: "robotic" },
96
+ { id: "robot", label: "Robot", description: "Synthetic, mechanical", promptHint: "robotic", term: "robotic" },
87
97
  { id: "ai-assistant", label: "AI Assistant", description: "Polished synthetic helper", promptHint: "AI assistant" },
88
98
  { id: "siri-style", label: "Virtual Assistant", description: "Siri/Alexa-style helper", promptHint: "virtual assistant" },
89
99
  ] as const
@@ -96,6 +106,23 @@ export function getVoicePace(id: string | undefined) { return id ? PACE_BY_ID.ge
96
106
  export function getVoiceEmotion(id: string | undefined) { return id ? EMOTION_BY_ID.get(id) : undefined }
97
107
  export function getVoiceArchetype(id: string | undefined) { return id ? ARCHETYPE_BY_ID.get(id) : undefined }
98
108
 
109
+ /**
110
+ * The COMPACT counterparts of the three lookups above: the short professional
111
+ * term a consumer injects instead of the full prompt fragment ("drawled",
112
+ * "reassuring", "nature documentary narrator"). Same lookup, same
113
+ * empty-string-on-miss behavior as the hint side, so the two can never
114
+ * disagree about which entry they describe.
115
+ */
116
+ export function getVoicePaceTerm(id: string | undefined | null): string {
117
+ return resolveTerm(getVoicePace(id ?? undefined))
118
+ }
119
+ export function getVoiceEmotionTerm(id: string | undefined | null): string {
120
+ return resolveTerm(getVoiceEmotion(id ?? undefined))
121
+ }
122
+ export function getVoiceArchetypeTerm(id: string | undefined | null): string {
123
+ return resolveTerm(getVoiceArchetype(id ?? undefined))
124
+ }
125
+
99
126
  /**
100
127
  * Compose a delivery clause.
101
128
  * Examples:
@@ -104,13 +131,17 @@ export function getVoiceArchetype(id: string | undefined) { return id ? ARCHETYP
104
131
  * { emotion } → "reassuring tone"
105
132
  * { pace } → "measured pace"
106
133
  */
107
- export function buildVoiceDeliveryHints(data: {
108
- readonly preText?: string
109
- readonly postText?: string
110
- readonly pace?: string
111
- readonly emotion?: string
112
- readonly archetype?: string
113
- }): string {
134
+ export function buildVoiceDeliveryHints(
135
+ data: {
136
+ readonly preText?: string
137
+ readonly postText?: string
138
+ readonly pace?: string
139
+ readonly emotion?: string
140
+ readonly archetype?: string
141
+ },
142
+ mode: PickerHintMode = "full",
143
+ ): string {
144
+ if (mode === "compact") return buildVoiceDeliveryTerms(data)
114
145
  const fragments: string[] = []
115
146
  const pre = typeof data.preText === "string" ? data.preText.trim() : ""
116
147
  if (pre) fragments.push(pre)
@@ -137,6 +168,46 @@ export function buildVoiceDeliveryHints(data: {
137
168
  return fragments.join(", ")
138
169
  }
139
170
 
171
+ /**
172
+ * Compact-mode mirror of `buildVoiceDeliveryHints`: the same preText /
173
+ * [pace] [archetype]-style delivery / [emotion] tone / postText structure,
174
+ * built from each selection's short `term` instead of its `promptHint`
175
+ * ("drawled noir-detective-style delivery, menacing tone"). preText and
176
+ * postText — user free text, not catalog copy — pass through untouched.
177
+ */
178
+ export function buildVoiceDeliveryTerms(data: {
179
+ readonly preText?: string
180
+ readonly postText?: string
181
+ readonly pace?: string
182
+ readonly emotion?: string
183
+ readonly archetype?: string
184
+ }): string {
185
+ const fragments: string[] = []
186
+ const pre = typeof data.preText === "string" ? data.preText.trim() : ""
187
+ if (pre) fragments.push(pre)
188
+
189
+ const pace = getVoicePaceTerm(data.pace)
190
+ const emotion = getVoiceEmotionTerm(data.emotion)
191
+ const archetype = getVoiceArchetypeTerm(data.archetype)
192
+
193
+ let deliveryClause = ""
194
+ if (pace && archetype) deliveryClause = `${pace} ${archetype}-style delivery`
195
+ else if (archetype) deliveryClause = `${archetype}-style delivery`
196
+ else if (pace) deliveryClause = `${pace} pace`
197
+
198
+ const emotionClause = emotion ? `${emotion} tone` : ""
199
+
200
+ let main = ""
201
+ if (deliveryClause && emotionClause) main = `${deliveryClause}, ${emotionClause}`
202
+ else main = deliveryClause || emotionClause
203
+ if (main) fragments.push(main)
204
+
205
+ const post = typeof data.postText === "string" ? data.postText.trim() : ""
206
+ if (post) fragments.push(post)
207
+
208
+ return fragments.join(", ")
209
+ }
210
+
140
211
  export const VOICE_DELIVERY_DEFAULT_DATA: {
141
212
  preText?: string; postText?: string; pace?: string; emotion?: string; archetype?: string
142
213
  } = {}