techfreakworm commited on
Commit
ed5f3e7
Β·
unverified Β·
1 Parent(s): 8d8e3d7

docs: spec for param expansion + Dialog mode

Browse files
docs/superpowers/specs/2026-04-29-param-expansion-and-dialog-design.md ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Param Expansion + Dialog Mode β€” Design Spec
2
+
3
+ **Date:** 2026-04-29
4
+ **Status:** Approved (Sections 1–3) β€” ready for implementation plan
5
+ **Repo:** `/Users/techfreakworm/Projects/llm/chatterbox-voicecloner`
6
+ **Builds on:** `docs/superpowers/specs/2026-04-28-chatterbox-voice-studio-design.md`
7
+
8
+ ---
9
+
10
+ ## 1. Problem & Goals
11
+
12
+ The first-cut studio exposes only `exaggeration`, `cfg_weight`, and `temperature` per adapter. The underlying `chatterbox-tts` package supports more knobs (samplers, repetition penalty, reproducibility seeds, top-k for turbo). The popular comfyui community node *FL Chatterbox* exposes these and a fourth "Dialog TTS" workflow that synthesizes multi-speaker scenes by routing each `SPEAKER A:` / `SPEAKER B:` line to the matching reference clip.
13
+
14
+ This spec adds:
15
+
16
+ 1. **Full parameter coverage** for all three existing adapters (en/turbo/mtl), grouped into Basic and Advanced.
17
+ 2. **Reproducibility** via a `seed` parameter (with `-1` = random, used seed echoed back).
18
+ 3. **Dialog mode** β€” a multi-speaker composer that runs the user-chosen engine per turn and concatenates the result.
19
+
20
+ ### Non-goals
21
+
22
+ - "Control after generate" comfyui-style dropdown (randomize / fixed / increment). The `-1`-as-random convention covers it.
23
+ - Per-turn param overrides in Dialog mode.
24
+ - Crossfade between Dialog turns (fixed 250ms silence).
25
+ - More than 4 speakers (A–D, matching comfyui).
26
+ - Voice conversion (`chatterbox.vc`).
27
+ - Streaming partial Dialog playback β€” full WAV returned.
28
+
29
+ ### Success criteria
30
+
31
+ 1. Each adapter's `/api/models` entry returns the expanded `params` list with `group` set on every entry.
32
+ 2. The frontend renders Basic params always-visible and Advanced params inside a collapsible disclosure.
33
+ 3. `seed=-1` makes each generation use a fresh random seed; the response header `X-Seed-Used` carries the actual seed; the History row shows that seed and provides a one-click "reuse" button.
34
+ 4. A new `/api/generate/dialog` endpoint accepts 1–4 reference clips, parses `SPEAKER X:` text, generates per turn with the chosen engine, and returns a single concatenated WAV.
35
+ 5. Dialog mode is selectable via a `Single voice / Dialog` mode toggle in the Studio header area; the composer adapts.
36
+ 6. Existing single-voice flow continues to work unchanged.
37
+
38
+ ---
39
+
40
+ ## 2. Decisions Locked In
41
+
42
+ | # | Decision | Rationale |
43
+ |---|---|---|
44
+ | Q1 | **B β€” Dialog lets user pick underlying engine per session** (en/turbo/mtl) | Lets you do English-fast, English-expressive, and 23-language dialog from the same UI. Keeps engine switching honest (mtl needs the language picker). |
45
+ | Q2 | **C β€” Hide seed and rarely-used params behind an Advanced toggle** | Most generations don't tune samplers; the disclosure keeps the form scannable while still allowing full control. |
46
+ | Q3 | **A β€” Take the proposed Basic/Advanced split as-is** | See Β§4.2 for the per-adapter split. |
47
+
48
+ ---
49
+
50
+ ## 3. Architecture Delta
51
+
52
+ ```
53
+ chatterbox-voicecloner/
54
+ β”œβ”€β”€ server/
55
+ β”‚ β”œβ”€β”€ seed.py (NEW)
56
+ β”‚ β”œβ”€β”€ dialog.py (NEW)
57
+ β”‚ β”œβ”€β”€ schemas.py + ParamSpec.group
58
+ β”‚ β”œβ”€β”€ main.py + /api/generate/dialog, X-Seed-Used header
59
+ β”‚ └── models/
60
+ β”‚ β”œβ”€β”€ chatterbox_en.py expanded params + seed
61
+ β”‚ β”œβ”€β”€ chatterbox_turbo.py expanded params + seed
62
+ β”‚ └── chatterbox_mtl.py expanded params + seed
63
+ β”œβ”€β”€ tests/
64
+ β”‚ β”œβ”€β”€ test_seed.py (NEW)
65
+ β”‚ β”œβ”€β”€ test_dialog_parser.py (NEW)
66
+ β”‚ β”œβ”€β”€ test_dialog_endpoint.py (NEW)
67
+ β”‚ β”œβ”€β”€ test_adapter_contract.py assert every param has a valid group
68
+ β”‚ └── test_main_generate.py assert X-Seed-Used header present
69
+ β”œβ”€β”€ web/
70
+ β”‚ └── src/
71
+ β”‚ β”œβ”€β”€ components/
72
+ β”‚ β”‚ β”œβ”€β”€ ModeToggle.tsx (NEW)
73
+ β”‚ β”‚ β”œβ”€β”€ SpeakerSlot.tsx (NEW)
74
+ β”‚ β”‚ β”œβ”€β”€ DialogComposer.tsx (NEW)
75
+ β”‚ β”‚ β”œβ”€β”€ ParamsPanel.tsx basic vs advanced disclosure
76
+ β”‚ β”‚ β”œβ”€β”€ HistoryList.tsx seed display + reuse button + dialog badge
77
+ β”‚ β”‚ └── TagBar.tsx (unchanged behavior)
78
+ β”‚ β”œβ”€β”€ lib/
79
+ β”‚ β”‚ β”œβ”€β”€ api.ts + generateDialog(); read X-Seed-Used
80
+ β”‚ β”‚ └── idb.ts HistoryRecord: + kind, seedUsed, speakers?
81
+ β”‚ └── pages/Studio.tsx mode-aware composer
82
+ └── scripts/smoke.sh + dialog smoke step
83
+ ```
84
+
85
+ No changes to launcher scripts, Dockerfile, or repo-level files.
86
+
87
+ ---
88
+
89
+ ## 4. Backend
90
+
91
+ ### 4.1 `ParamSpec.group`
92
+
93
+ ```python
94
+ ParamGroup = Literal["basic", "advanced"]
95
+
96
+ class ParamSpec(BaseModel):
97
+ ...
98
+ group: ParamGroup = "basic"
99
+ ```
100
+
101
+ `/api/models` already returns `params` via `model_dump()`, so the new field flows to the frontend automatically.
102
+
103
+ ### 4.2 Per-adapter parameter table
104
+
105
+ Defaults reflect either the underlying `chatterbox-tts` defaults or the FL Chatterbox node defaults, whichever is more reasonable for a studio.
106
+
107
+ | Adapter | Basic | Advanced |
108
+ |---|---|---|
109
+ | **chatterbox-en** | `exaggeration` (0.5, 0–2, .05), `cfg_weight` (0.5, 0–1, .05), `temperature` (0.8, 0.1–1.5, .05) | `seed` (int, default βˆ’1), `repetition_penalty` (1.2, 1.0–3.0, .05), `min_p` (0.05, 0.0–1.0, .01), `top_p` (1.0, 0.0–1.0, .01) |
110
+ | **chatterbox-turbo** | `temperature` (0.8, 0.1–1.5, .05), `top_p` (0.95, 0.0–1.0, .01), `repetition_penalty` (1.2, 1.0–3.0, .05) | `seed` (βˆ’1), `top_k` (1000, 1–4000, 1), `exaggeration` (0.0, 0–2, .05), `cfg_weight` (0.0, 0–1, .05) |
111
+ | **chatterbox-mtl** | `exaggeration` (0.5, 0–2, .05), `cfg_weight` (0.5, 0–1, .05), `temperature` (0.8, 0.1–1.5, .05), `repetition_penalty` (2.0, 1.0–3.0, .05) | `seed` (βˆ’1), `min_p` (0.05, 0.0–1.0, .01), `top_p` (1.0, 0.0–1.0, .01) |
112
+
113
+ `seed` is rendered by the frontend as a special "int with random" control (see Β§5.4); for the adapter contract it's just `type: "int"`, `default: -1`, `min: -1`.
114
+
115
+ Each adapter's `generate()` is extended with these kwargs; existing calls with smaller param dicts still work because each kwarg has a default in the adapter.
116
+
117
+ ### 4.3 `server/seed.py` β€” seed helper
118
+
119
+ ```python
120
+ import random
121
+ import torch
122
+
123
+
124
+ def apply_seed(seed: int | None) -> int:
125
+ """Return the seed actually used. If seed is None or -1, draw one."""
126
+ if seed is None or seed < 0:
127
+ seed = random.randint(0, 2**31 - 1)
128
+ torch.manual_seed(seed)
129
+ if torch.cuda.is_available():
130
+ torch.cuda.manual_seed_all(seed)
131
+ mps = getattr(torch, "mps", None)
132
+ if mps is not None:
133
+ try:
134
+ mps.manual_seed(seed)
135
+ except Exception:
136
+ pass
137
+ random.seed(seed)
138
+ return seed
139
+ ```
140
+
141
+ Each `generate()` calls `seed_used = apply_seed(params.get("seed"))` immediately before invoking the underlying model. The endpoint wraps the call and adds `X-Seed-Used: {seed_used}` to the response.
142
+
143
+ ### 4.4 `X-Seed-Used` response header
144
+
145
+ `/api/generate` and `/api/generate/dialog` both set this header. Adapter `generate()` returns `(wav_bytes, sample_rate, seed_used)` instead of `(wav_bytes, sample_rate)`. The endpoint adds the header before returning the streaming response.
146
+
147
+ This is a contract change visible to existing tests:
148
+
149
+ - `test_main_generate.py` β€” assert header present and parseable as int.
150
+ - The `FakeAdapter` in `conftest.py` returns a stable `seed_used = 0`.
151
+
152
+ ### 4.5 Dialog parser (`server/dialog.py`)
153
+
154
+ ```python
155
+ import re
156
+ from dataclasses import dataclass
157
+
158
+ _SPEAKER_RE = re.compile(r"^\s*SPEAKER\s+([A-D])\s*:\s*", re.MULTILINE)
159
+
160
+
161
+ @dataclass
162
+ class DialogTurn:
163
+ speaker: str # "A" | "B" | "C" | "D"
164
+ text: str
165
+
166
+
167
+ class DialogParseError(ValueError):
168
+ pass
169
+
170
+
171
+ def parse_dialog(text: str) -> list[DialogTurn]:
172
+ matches = list(_SPEAKER_RE.finditer(text))
173
+ if not matches:
174
+ raise DialogParseError(
175
+ "Use SPEAKER A: ... / SPEAKER B: ... lines to define turns."
176
+ )
177
+ turns: list[DialogTurn] = []
178
+ for i, m in enumerate(matches):
179
+ start = m.end()
180
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
181
+ block = text[start:end].strip()
182
+ if block:
183
+ turns.append(DialogTurn(speaker=m.group(1), text=block))
184
+ if not turns:
185
+ raise DialogParseError("No non-empty speaker turns found.")
186
+ return turns
187
+ ```
188
+
189
+ ### 4.6 Dialog generator
190
+
191
+ `server/dialog.py` also exposes `generate_dialog()`:
192
+
193
+ ```python
194
+ async def generate_dialog(
195
+ *,
196
+ registry: Registry,
197
+ engine_id: str,
198
+ text: str,
199
+ language: str | None,
200
+ params: dict,
201
+ speaker_refs: dict[str, str], # letter -> filesystem path (already temped)
202
+ silence_ms: int = 250,
203
+ ) -> tuple[bytes, int, int]: # (wav_bytes, sample_rate, seed_used)
204
+ ```
205
+
206
+ Algorithm:
207
+
208
+ 1. `parse_dialog(text)` β†’ list of turns.
209
+ 2. Validate: every turn's speaker letter has an entry in `speaker_refs`. Otherwise raise `DialogParseError("missing reference for speaker X")`.
210
+ 3. `adapter = await registry.get_or_load(engine_id)`.
211
+ 4. Resolve a single seed up front via `apply_seed(params.get("seed"))`. Re-apply this same value before each turn so the runs are reproducible together.
212
+ 5. For each turn: `wav_bytes, sr, _ = adapter.generate(text=turn.text, reference_wav_path=speaker_refs[turn.speaker], language=language, params=params)`. Decode `wav_bytes` to mono float32 numpy.
213
+ 6. Concatenate all turn arrays separated by `silence_ms` of zeros at the engine's sample rate.
214
+ 7. Re-encode with `server.audio.write_wav_bytes` at `sr`.
215
+ 8. Return `(wav, sr, seed_used)`.
216
+
217
+ ### 4.7 New endpoint `POST /api/generate/dialog`
218
+
219
+ Multipart fields:
220
+
221
+ | Field | Required | Notes |
222
+ |---|---|---|
223
+ | `text` | βœ“ | Body of the dialog with `SPEAKER X:` prefixes. |
224
+ | `engine_id` | βœ“ | One of `chatterbox-en`, `chatterbox-turbo`, `chatterbox-mtl`. |
225
+ | `language` | conditionally | Required iff `engine_id == "chatterbox-mtl"`. |
226
+ | `params` | βœ“ | JSON object β€” same shape as `/api/generate`. |
227
+ | `reference_wav_a` | conditionally | Required iff a SPEAKER A turn appears. Validated via `validate_reference_clip`. |
228
+ | `reference_wav_b` | conditionally | Required iff B appears. |
229
+ | `reference_wav_c` | conditionally | Required iff C appears. |
230
+ | `reference_wav_d` | conditionally | Required iff D appears. |
231
+
232
+ Errors:
233
+
234
+ - `400 dialog_format_invalid` β€” text has no SPEAKER tags.
235
+ - `400 dialog_missing_reference` β€” turn references a speaker with no clip uploaded.
236
+ - `400 reference_invalid` β€” a clip failed validation (forwarded from `validate_reference_clip`).
237
+ - `400 language_unsupported` β€” mtl engine without `language`.
238
+ - `404 model_not_found` β€” bad `engine_id`.
239
+ - `500 generation_failed` β€” adapter raised mid-dialog.
240
+
241
+ Response: `audio/wav` bytes; header `X-Seed-Used: <int>`.
242
+
243
+ ### 4.8 `/api/generate` (existing, single-voice)
244
+
245
+ Adds the `X-Seed-Used` header. No other change. The existing `params` field already accepts arbitrary keys, so the new sampler params (`seed`, `repetition_penalty`, `min_p`, `top_p`, `top_k`) flow through with no schema change.
246
+
247
+ ### 4.9 Adapter contract test extension
248
+
249
+ `test_adapter_contract.py` adds:
250
+
251
+ ```python
252
+ def test_param_groups_are_valid(module_name):
253
+ cls = importlib.import_module(module_name).Adapter
254
+ for p in cls.params:
255
+ assert p.group in {"basic", "advanced"}
256
+ ```
257
+
258
+ ---
259
+
260
+ ## 5. Frontend
261
+
262
+ ### 5.1 Mode toggle (`ModeToggle.tsx`)
263
+
264
+ Segmented control with two options: `Single voice` and `Dialog`. Lives in the header row, to the left of the model picker. Mode is hoisted into `Studio.tsx` state.
265
+
266
+ ### 5.2 ParamsPanel: Basic / Advanced
267
+
268
+ ```tsx
269
+ const basic = specs.filter(s => (s.group ?? "basic") === "basic");
270
+ const advanced = specs.filter(s => s.group === "advanced");
271
+
272
+ return (
273
+ <div className="space-y-5">
274
+ {basic.map(...)}
275
+ {advanced.length > 0 && (
276
+ <details className="card-paper p-3">
277
+ <summary className="label-mono cursor-pointer select-none">
278
+ β–Έ advanced Β· {advanced.length} params
279
+ </summary>
280
+ <div className="mt-3 space-y-5">
281
+ {advanced.map(...)}
282
+ </div>
283
+ </details>
284
+ )}
285
+ </div>
286
+ );
287
+ ```
288
+
289
+ The summary swaps `β–Έ` ↔ `β–Ύ` via the native `<details>` open state.
290
+
291
+ ### 5.3 Seed control
292
+
293
+ Special-cased in `ParamsPanel` when `spec.name === "seed"`:
294
+
295
+ ```tsx
296
+ <div className="flex items-baseline gap-3">
297
+ <label className="label-mono">Seed</label>
298
+ <input
299
+ type="number"
300
+ value={seedValue}
301
+ onChange={...}
302
+ className="field-input !w-40 font-mono text-[12px] py-1"
303
+ min={-1}
304
+ />
305
+ <button onClick={() => set("seed", -1)} className="label-mono hover:text-foreground">
306
+ ↻ random
307
+ </button>
308
+ {seedValue === -1 && (
309
+ <span className="label-mono text-muted-foreground">(random per generate)</span>
310
+ )}
311
+ </div>
312
+ ```
313
+
314
+ ### 5.4 History row updates
315
+
316
+ Each `HistoryRecord` gains:
317
+
318
+ ```ts
319
+ type HistoryRecord = {
320
+ ...
321
+ kind: "single" | "dialog";
322
+ seedUsed?: number;
323
+ speakers?: { letter: string; voiceId: number }[]; // dialog-only
324
+ };
325
+ ```
326
+
327
+ Schema migration handled at IndexedDB level by Dexie's `version(2)` upgrade with `kind: "single"` set on existing rows.
328
+
329
+ `HistoryList` renders:
330
+
331
+ - A `dialog Β· 2 spk Β· en` badge for dialog rows.
332
+ - A `seed 84233927 Β· ↻` element on every row. Clicking `↻` calls `onReuse(seed, params, ...)` to set the active params to those values. Studio.tsx handles propagating to the right composer.
333
+
334
+ ### 5.5 Dialog composer (`DialogComposer.tsx`, `SpeakerSlot.tsx`)
335
+
336
+ State:
337
+
338
+ ```ts
339
+ type Speakers = Partial<Record<"A" | "B" | "C" | "D", VoiceRecord>>;
340
+ ```
341
+
342
+ UI sections (top to bottom):
343
+
344
+ 1. **Speakers** β€” list of A/B (and optionally C/D) rows. Each row: letter badge, voice picker that opens the existing `VoiceLibrary` in select mode, βœ• to remove. `+ add speaker` button while count < 4.
345
+ 2. **Engine** β€” radio group of the three real adapter labels. When `chatterbox-mtl` is picked, a language `<select>` appears beside it.
346
+ 3. **Script** β€” same multi-line textarea + tag bar. The script gets a small helper row of speaker chips (`+ SPEAKER A`, etc.) that insert at the cursor. The chips list is gated by the speakers configured in step 1.
347
+ 4. **Parameters** β€” the chosen engine's `ParamsPanel` (basic + advanced disclosure).
348
+ 5. **Generate dialog** β€” primary CTA. Disabled while engine is loading or text is blank.
349
+
350
+ On submit, `DialogComposer` calls `lib/api.ts:generateDialog(...)`:
351
+
352
+ ```ts
353
+ export async function generateDialog(input: {
354
+ engineId: string;
355
+ text: string;
356
+ language?: string;
357
+ params: Record<string, unknown>;
358
+ speakers: { letter: "A"|"B"|"C"|"D"; reference: Blob }[];
359
+ }): Promise<{ blob: Blob; seedUsed: number | null }> { ... }
360
+ ```
361
+
362
+ The function builds multipart with `reference_wav_<letter>` keys, posts, reads `X-Seed-Used`, returns both.
363
+
364
+ ### 5.6 Studio integration
365
+
366
+ `Studio.tsx` decides between `<SingleComposer>` and `<DialogComposer>` based on `mode`. Shared chrome (header, banners, history pane) stays put.
367
+
368
+ ### 5.7 Tests
369
+
370
+ | File | Cases |
371
+ |---|---|
372
+ | `ParamsPanel.test.tsx` (extended) | basic always rendered; advanced hidden by default; opening the disclosure reveals advanced params; advanced edits propagate; `seed=-1` shows the "(random per generate)" hint. |
373
+ | `DialogComposer.test.tsx` (new) | starts with two speaker slots A and B; "+ add speaker" appends C; can't exceed 4; engine pick toggles language picker on mtl; helper chips inject at cursor. |
374
+ | `idb.test.ts` (extended) | migrating from v1 to v2 sets `kind: "single"` on existing rows; round-trip of dialog row with `speakers` and `seedUsed`. |
375
+ | `api.test.ts` (extended) | `generateDialog` posts the right multipart fields; reads `X-Seed-Used` from response headers. |
376
+
377
+ ---
378
+
379
+ ## 6. Edge Cases (frozen)
380
+
381
+ | Case | Resolution |
382
+ |---|---|
383
+ | Dialog text has no SPEAKER prefix | 400 `dialog_format_invalid`. |
384
+ | Turn references unloaded speaker | 400 `dialog_missing_reference`. |
385
+ | Multi-line block within a single speaker turn | All lines until next `SPEAKER X:` belong to that speaker. |
386
+ | Same speaker in 3 consecutive turns | Three separate generate calls, three concatenated outputs. |
387
+ | One turn longer than engine's max input | Forwarded β€” `generation_failed` surfaces the engine error. |
388
+ | mtl + dialog without `language` | 400 `language_unsupported`. |
389
+ | Seed `-1` in dialog | One seed drawn; reapplied before each turn for reproducibility. |
390
+ | Migration of legacy History rows (no `kind`) | Dexie v2 upgrade sets `kind: "single"`. |
391
+ | MPS-specific seed | `apply_seed` calls `torch.mps.manual_seed` only if available; failures swallowed. |
392
+
393
+ ---
394
+
395
+ ## 7. Implementation Order (preview)
396
+
397
+ 1. `ParamSpec.group` + adapter param expansion (en/turbo/mtl) + `apply_seed` helper + tests.
398
+ 2. Wire `X-Seed-Used` response header in `/api/generate`; `FakeAdapter` returns a fixed seed; tests.
399
+ 3. Frontend ParamsPanel basic/advanced split + seed control + History seed display + reuse button.
400
+ 4. `dialog.py` parser + tests.
401
+ 5. `dialog.py` generator + `/api/generate/dialog` endpoint + tests.
402
+ 6. `ModeToggle`, `SpeakerSlot`, `DialogComposer` components + tests.
403
+ 7. Studio integration (mode-aware render).
404
+ 8. Smoke script extension; manual e2e on Mac (en, turbo, mtl single + en dialog).
405
+
406
+ Each phase ends with a green `pytest`/`vitest`/build and a sole-author commit per the existing CLAUDE.md policy.
407
+
408
+ ---
409
+
410
+ *End of design spec.*