Replace ZeroGPU demo with Gradio studio and H200 endpoint

#6
by andito HF Staff - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .dockerignore +8 -0
  2. .gitattributes +1 -0
  3. .gitignore +4 -0
  4. Dockerfile +23 -0
  5. Dockerfile.engine +65 -0
  6. README.md +217 -79
  7. accounts.py +262 -0
  8. app.py +0 -776
  9. drama3_space_ext.py +292 -0
  10. endpoint/.gitignore +6 -0
  11. endpoint/PERFORMANCE.md +78 -0
  12. endpoint/README.md +143 -0
  13. endpoint/analyze_comparison.py +155 -0
  14. endpoint/benchmark_speed.py +138 -0
  15. endpoint/benchmark_transport.py +135 -0
  16. endpoint/compare.py +169 -0
  17. endpoint/deploy.py +51 -0
  18. endpoint/deployment.json +50 -0
  19. endpoint/verify.py +71 -0
  20. endpoint/watch.py +38 -0
  21. entitlements.py +134 -0
  22. fish_speech/LICENSE +0 -114
  23. fish_speech/__init__.py +0 -1
  24. fish_speech/codec.py +0 -113
  25. fish_speech/models/__init__.py +0 -1
  26. fish_speech/models/dac/__init__.py +0 -1
  27. fish_speech/models/dac/dac_nn.py +0 -285
  28. fish_speech/models/dac/modded_dac.py +0 -991
  29. fish_speech/models/dac/rvq.py +0 -371
  30. fish_speech/models/text2semantic/__init__.py +0 -1
  31. fish_speech/models/text2semantic/errors.py +0 -5
  32. fish_speech/models/text2semantic/fish_omni/__init__.py +0 -1
  33. fish_speech/models/text2semantic/fish_omni/backbone.py +0 -397
  34. fish_speech/models/text2semantic/fish_omni/chunking.py +0 -206
  35. fish_speech/models/text2semantic/fish_omni/config.py +0 -249
  36. fish_speech/models/text2semantic/fish_omni/fp8_moe.py +0 -355
  37. fish_speech/models/text2semantic/fish_omni/generate.py +0 -505
  38. fish_speech/models/text2semantic/fish_omni/layers.py +0 -410
  39. fish_speech/models/text2semantic/fish_omni/model.py +0 -147
  40. fish_speech/models/text2semantic/fish_omni/moe.py +0 -223
  41. fish_speech/models/text2semantic/fish_omni/tower.py +0 -421
  42. fish_speech/models/text2semantic/sampling.py +0 -9
  43. footer.html +11 -0
  44. gradio_ui.py +479 -0
  45. hero.html +13 -0
  46. index.html +1210 -0
  47. requirements-preview.txt +8 -0
  48. requirements-ui.txt +7 -0
  49. requirements.txt +0 -15
  50. scripts/omni_missing_deps.py +63 -0
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .venv
3
+ __pycache__
4
+ .pytest_cache
5
+ **/__pycache__
6
+ **/*.pyc
7
+ endpoint/comparisons
8
+ endpoint/results
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.wav filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .venv/
4
+ *.pyc
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The Space hosts the Gradio UI on CPU. The pinned H200 endpoint serves Drama 3.
2
+ # Dockerfile.engine retains the GPU image recipe used by that endpoint.
3
+ FROM python:3.11-slim-bookworm
4
+ RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \
5
+ && rm -rf /var/lib/apt/lists/* \
6
+ && useradd -m -u 1000 user
7
+ WORKDIR /home/user/app
8
+ COPY requirements-ui.txt requirements-preview.txt /tmp/
9
+ RUN pip install --no-cache-dir -r /tmp/requirements-ui.txt
10
+ ENV HOME=/home/user \
11
+ USER=user \
12
+ PYTHONUNBUFFERED=1 \
13
+ GRADIO_ANALYTICS_ENABLED=False \
14
+ WHISPER_DIR=/home/user/.cache/whisper \
15
+ DEMO_UNLIMITED_ORGS=fishaudio \
16
+ USAGE_BUCKET_MOUNT_PATH=/data \
17
+ DRAMA_ENDPOINT_URL=https://6acfbd40fda97227704d0f72.endpoints.huggingface.cloud
18
+ USER user
19
+ # Cache the same Whisper turbo model for CPU transcription, independently of UI edits.
20
+ RUN python -c "from faster_whisper import download_model; download_model('turbo', cache_dir='/home/user/.cache/whisper')"
21
+ COPY --chown=user:user . .
22
+ EXPOSE 7860
23
+ CMD ["python", "server.py"]
Dockerfile.engine ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fish Audio Drama 3 on SGLang Omni, as a Hugging Face Docker Space on one GPU
2
+ # (RTX Pro 6000 or H200).
3
+ #
4
+ # The base image is the one SGLang Omni's CI runs main on: CUDA, SGLang, and most
5
+ # Python dependencies, but not sglang-omni itself, which is installed at a pinned commit.
6
+ FROM hongccc/sglang-omni@sha256:ebe4239e29a764ee3a2806385c061c5fd438a26f01458e503d3822dcba5790df
7
+
8
+ ARG SGLANG_OMNI_COMMIT=22d4ace88f9547777b819a69d7691204bb9ce847
9
+
10
+ # The image moves slower than SGLang Omni (its SGLang predates the pinned commit's
11
+ # sglang==0.5.21). Install the commit's pins the image does not satisfy, as SGLang
12
+ # Omni's CI does before testing on this image (.github/scripts/prepare_omni_venv.sh).
13
+ COPY scripts/omni_missing_deps.py /tmp/omni_missing_deps.py
14
+ RUN python3 -c "import sys, urllib.request; urllib.request.urlretrieve(sys.argv[1], '/tmp/omni-pyproject.toml')" \
15
+ "https://raw.githubusercontent.com/sgl-project/sglang-omni/${SGLANG_OMNI_COMMIT}/pyproject.toml" \
16
+ && python3 /tmp/omni_missing_deps.py /tmp/omni-pyproject.toml > /tmp/omni-missing.txt \
17
+ && echo "Pins the image does not satisfy:" && cat /tmp/omni-missing.txt \
18
+ && if [ -s /tmp/omni-missing.txt ]; then xargs -d '\n' -n 1 python3 -m pip install --no-cache-dir < /tmp/omni-missing.txt; fi \
19
+ && python3 /tmp/omni_missing_deps.py --overrides /tmp/omni-pyproject.toml \
20
+ | xargs -r -d '\n' python3 -m pip install --no-cache-dir --no-deps \
21
+ && python3 /tmp/omni_missing_deps.py --report /tmp/omni-pyproject.toml
22
+
23
+ RUN python3 -m pip install --no-cache-dir --no-deps \
24
+ "sglang-omni @ https://github.com/sgl-project/sglang-omni/archive/${SGLANG_OMNI_COMMIT}.tar.gz" \
25
+ python-multipart \
26
+ && python3 -m pip install --no-cache-dir authlib itsdangerous "gradio[oauth]==6.28.0" \
27
+ && python3 -c "import sglang_omni, whisper, av, librosa, httpx, fastapi, huggingface_hub; from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth; print('sglang-omni', sglang_omni.__version__, 'huggingface_hub', huggingface_hub.__version__)"
28
+
29
+ # SGLang Omni declares each stage's factory in the model's config class and refuses
30
+ # to override it from the command line. Rebind the class name the Drama 3
31
+ # preprocessing factory builds to the Space's subclass, which adds described
32
+ # characters and directing history (drama3_space_ext.py, on PYTHONPATH at runtime).
33
+ RUN stages=$(python3 -c "import os, sglang_omni; print(os.path.join(os.path.dirname(sglang_omni.__file__), 'models', 'fishaudio_drama3', 'stages.py'))") \
34
+ && grep -q "^def create_preprocessing_executor" "$stages" \
35
+ && grep -q "preprocessor = Drama3SpeechPreprocessor(" "$stages" \
36
+ && printf '\n\n# Drama 3 Space: described characters and directing history.\nfrom drama3_space_ext import Drama3SpacePreprocessor as Drama3SpeechPreprocessor # noqa: E402\n' >> "$stages" \
37
+ && tail -3 "$stages"
38
+
39
+ # Spaces run the container as uid 1000. Give it a writable home that holds the
40
+ # FlashInfer JIT cache the image built for Hopper, and stop the base entrypoint from
41
+ # cloning SGLang Omni's main branch at start.
42
+ RUN (id -u 1000 >/dev/null 2>&1 || useradd -m -u 1000 -s /bin/bash user) \
43
+ && mkdir -p /home/user/app /home/user/.cache \
44
+ && if [ -d /root/.cache/flashinfer ]; then cp -a /root/.cache/flashinfer /home/user/.cache/; fi \
45
+ && chown -R 1000:1000 /home/user
46
+ ENV HOME=/home/user \
47
+ USER=user \
48
+ FLASHINFER_WORKSPACE_BASE=/home/user \
49
+ SGLANG_OMNI_AUTO_CLONE=0 \
50
+ PYTHONUNBUFFERED=1 \
51
+ PYTHONPATH=/home/user/app \
52
+ WHISPER_DIR=/home/user/.cache/whisper \
53
+ DEMO_UNLIMITED_ORGS=fishaudio \
54
+ USAGE_BUCKET_MOUNT_PATH=/data
55
+
56
+ USER 1000
57
+ WORKDIR /home/user/app
58
+
59
+ # Whisper large-v3-turbo transcribes reference clips (about 1.5 GB).
60
+ RUN python3 -c "import whisper; whisper._download(whisper._MODELS['turbo'], '/home/user/.cache/whisper', False)"
61
+
62
+ COPY --chown=1000:1000 . .
63
+
64
+ EXPOSE 7860
65
+ CMD ["python3", "server.py"]
README.md CHANGED
@@ -1,107 +1,245 @@
1
  ---
2
  title: Drama 3
3
- emoji: 👀
4
  colorFrom: purple
5
  colorTo: red
6
- sdk: gradio
7
- sdk_version: 6.30.0
8
- python_version: '3.12'
9
- app_file: app.py
10
  pinned: false
11
- preload_from_hub:
12
- - fishaudio/drama-3
13
- startup_duration_timeout: 1h
14
  models:
15
  - fishaudio/drama-3
16
- license: apache-2.0
17
- short_description: Fish Audio Drama 3 Work Space!
18
  tags:
19
- - ml-intern
20
  - text-to-speech
21
  - fish-audio
22
- - zerogpu
 
 
 
23
  ---
24
 
25
- [![Built with ML Intern](https://huggingface.co/datasets/huggingface/badges/resolve/main/built-with-ml-intern-sm.svg)](https://hf.co/chat?mode=ml-intern)
26
-
27
- # Fish Audio Drama 3 — single-speaker voice cloning
28
-
29
- A Gradio app that runs
30
- **[fishaudio/drama-3](https://huggingface.co/fishaudio/drama-3)** (35B MoE, 3B
31
- active per token) **in-process on a ZeroGPU `large` slice** (RTX Pro 6000, 48 GB)
32
- via one `@spaces.GPU` function. Record or upload a voice sample, type what that
33
- sample says, and generate a new line in that voice.
34
-
35
- > **Hardware:** this Space must run on **ZeroGPU** hardware (Settings → Hardware → ZeroGPU).
36
- > On CPU hardware the page opens, but Generate asks you to flip the hardware.
37
-
38
- Inference uses the official PyTorch path of the model, vendored under `fish_speech/`
39
- (adapted from [hugging-apps/fish-audio-drama-3](https://huggingface.co/spaces/hugging-apps/fish-audio-drama-3)
40
- via [akhaliq/drama-3-tts](https://huggingface.co/spaces/akhaliq/drama-3-tts)):
41
- the 32.2B FP8 MoE experts stay in FP8 — dequantised they would need ~67 GB and
42
- would not fit the slice. The model loads at module scope, as the ZeroGPU docs
43
- recommend: built on the meta device, materialised on CPU with FP8 experts, then
44
- moved to `cuda`, so startup costs no GPU time. `spaces` is imported before CUDA
45
- is initialized.
46
-
47
- ## How to use
48
-
49
- The page has five controls:
50
-
51
- 1. **Reference audio** — upload a clip or record with the microphone. The clip
52
- must be 0.5–30 seconds of speech. A longer clip is rejected; it is not
53
- silently trimmed, because the transcript has to match the whole recording.
54
- 2. **Reference transcript** — type exactly what is spoken in the reference
55
- audio. This text only conditions the voice. It is not the line that gets
56
- synthesized.
57
- 3. **Text to generate** — type the new words you want that voice to say. Plain
58
- text is enough; it is mapped to the reference speaker internally. You do not
59
- need to write `S0:` or a speaker token.
60
- 4. **Generate** — one click runs one synthesis. Requests are queued, and this
61
- model accepts one generation at a time.
62
- 5. **Generated audio** — play or download the result. The page returns a single
63
- audio file.
64
-
65
- Example:
66
-
67
- - Reference transcript: `Hello, nice to meet you. My name is Chenyang.`
68
- - Text to generate: `Have a nice day and enjoy Southern California sunshine.`
69
-
70
- The output should say the new line in the reference voice, not repeat the
71
- introduction.
72
-
73
- This page is a single-speaker preview. Multi-speaker scenes, streaming, and the
74
- HTTP API are documented with SGLang Omni:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  - [Fish Audio Drama 3 cookbook](https://sgl-project.github.io/sglang-omni/cookbook/fishaudio_drama3.html): install, voice cloning, and the HTTP API
77
  - [SGLang Omni](https://github.com/sgl-project/sglang-omni): the GitHub repo — please give it a star
78
- - [Technical report](https://github.com/fishaudio/fish-speech/tree/main/docs/assets/Fish_Audio_Drama_3_Technical_Report.pdf): architecture of the 35B MoE model
79
  - [Fish Audio Playground](https://fish.audio): try Fish Audio voices in the browser
80
  - [Fish Speech](https://github.com/fishaudio/fish-speech): source repository for the model
81
 
82
- Sampling is fixed: `temperature=1.0`, `top_p=0.9`, `top_k=30`, and
83
- `max_new_tokens=500` per chunk. The token budget is also capped by the context
84
- window, so a chunk never asks for more frames than the prompt leaves free.
85
- Long text is split into chunks inside that single generation call.
86
-
87
- Each click books its own GPU slot with a dynamic `@spaces.GPU(duration=...)`
88
- estimate (≤120 s requested; ZeroGPU books 1.5× on this hardware). GPU time
89
- counts against the visitor's daily ZeroGPU quota — sign in for a larger
90
- allowance.
91
-
92
  ## Acknowledgments
93
 
94
  It took us over half a year to bring SGLang Omni back to life. Thanks to all the people who helped us along the way.
95
 
96
- Thanks so much for [AK](https://x.com/_akhaliq) help to set up the space.
97
 
98
  **SGLang Omni team:** [Chenyang Zhao](https://x.com/GenAI_is_real), Jingwen Gu, Shenggui Li, Richard Wang, Yichi Zhang, Xuesong Ye, Jiaxin Deng, Ratish P, Jiaxuan Luo, Xinyu Lu, Wenyao Gao, Ao Sun, Jintao Quaige Li, Yuhao Chen, Yuhan Zhou, Xuxiang Tian, Ruoyi Zhang, Junnan Li, Yixiao Zeng, Chenchen Hong, Huapeng Zhou, Yang Gao, Chaoye Fei, Yiyang Zh, Kang, Yitian Gong, Yuan Luo, Hao Jin, Yifei Gao, Gaokai Zhang, Lifan Shen, Hui Shen, Kejing Xia, Jinxin Li, Dongming Shen, Jun Liu, Yue Yin, Zhihao Guo, Jisen Li, Ruoyu He, Xuanyi Li, Chenye Zhu, Liang Zhen, Xiaodong Ye, Yizhi Zhen, Kaizuo Li, Chonghan Liu, Wei Guan, Zhuofeng Li
99
 
100
  **FishAudio Team:** Shijia Liao, Shidong Li, Xuehao Yang, Jiahua Liu
101
 
102
- ## Notes
103
 
104
- - Weights are preloaded at build time via `preload_from_hub`; first boot still
105
- takes a while while the ~40 GB checkpoint is packed.
106
- - Model license: [Fish Audio Community License](https://huggingface.co/fishaudio/drama-3/blob/main/LICENSE)
107
- — free non-commercial/research demo. Built with Fish Audio.
 
1
  ---
2
  title: Drama 3
3
+ emoji: 🎭
4
  colorFrom: purple
5
  colorTo: red
6
+ sdk: docker
7
+ app_port: 7860
8
+ hf_oauth: true
 
9
  pinned: false
10
+ license: apache-2.0
11
+ short_description: Direct Fish Audio Drama 3, streaming from one GPU
12
+ startup_duration_timeout: 30m
13
  models:
14
  - fishaudio/drama-3
15
+ - fishaudio/s2-pro
 
16
  tags:
 
17
  - text-to-speech
18
  - fish-audio
19
+ - sglang-omni
20
+ - voice-cloning
21
+ - voice-design
22
+ - streaming
23
  ---
24
 
25
+ # 🎭 Fish Audio Drama 3: direct a performance
26
+
27
+ Write a line, pick a voice, and direct it. Drama 3 streams the take back as it is
28
+ generated, and you can give the actor a note and get another take.
29
+
30
+ **[fishaudio/drama-3](https://huggingface.co/fishaudio/drama-3)** (35B MoE, 3B active
31
+ per token) runs on **one dedicated GPU** under
32
+ **[SGLang Omni](https://github.com/sgl-project/sglang-omni)**, the serving stack of the
33
+ model card's benchmarks (on an H200: about 90 ms to first audio and 7× faster than real
34
+ time for a single stream). Requests from different visitors are batched by the engine
35
+ instead of queuing behind each other.
36
+
37
+ The Space hosts the Gradio UI on **CPU Basic**. Generation runs on the authenticated
38
+ **HuggingFaceM4/drama-3-h200** Inference Endpoint, on one H200 in GCP us-south1.
39
+ UI rebuilds no longer download Drama 3 or restart its GPU server. The page shows
40
+ H200 and the median time to first audio; it does not report the remote GPU's memory.
41
+
42
+ ## What you can do
43
+
44
+ - **Clone** one of the three preset voices, upload a clip, or record with the
45
+ microphone. Clips are trimmed to 20 s at a pause and transcribed with Whisper, so
46
+ you never have to type the transcript (you can still edit it).
47
+ - **Design** a voice from a text description, with no audio at all: *"gravelly old
48
+ male voice, about seventy, weathered and slow"*. **Keep this voice** turns a take
49
+ you like into a clone reference, so later lines keep that voice.
50
+ - **Surprise**: no reference and no description; the model improvises a voice.
51
+ - **Direct** with the controls of the technical report (Appendix C), in plain language:
52
+ point tags `[laughs]`, scoped tags `<whisper>…</whisper>`, style prefixes
53
+ `[icy calm] Fine.`, and director's notes (`Direction: …` / `Line: …`).
54
+ - **Give a note** on a take (*"more amused than before; drop the hurt"*). The previous
55
+ takes go back into the prompt as earlier turns, so the note is read against what you
56
+ just heard.
57
+
58
+ ## Sign-in and quotas
59
+
60
+ Guests get 5 takes per UTC day without signing in. Signing in with Hugging Face gives
61
+ free accounts 20 more; Hugging Face PRO accounts, members of Team and Enterprise orgs,
62
+ and members of `DEMO_UNLIMITED_ORGS` (HuggingFaceM4 and fishaudio) have no daily cap.
63
+ A take counts once its audio starts, so refused requests are free. Every caller also
64
+ has at most 2 takes running at once.
65
+
66
+ Guests are counted by address and by a session cookie, whichever is higher. The Hub's
67
+ proxy passes a client-supplied `X-Forwarded-For` through, so a determined guest can
68
+ get around the guest allowance; the engine's global limits still bound the load.
69
+
70
+ Scripts can call the API with a Hugging Face token in an `X-HF-Token` header (or as a
71
+ Bearer token); the account's quota applies the same way.
72
+
73
+ As in the faster-qwen3-tts demo, usage is counted under HMACs, never raw ids, in
74
+ SQLite. Attach a Storage Bucket at `/data` to keep counts across restarts, and set
75
+ `DEMO_USAGE_HASH_SECRET` as a Space secret so the keys stay stable; without them the
76
+ counts reset when the Space restarts.
77
+
78
+ ## How it works
79
+
80
+ ```
81
+ browser ──Gradio──▶ server.py (CPU, :7860) ──authenticated SSE──▶ H200 endpoint
82
+ · queue + per-visitor limits · preprocessing → engine → vocoder
83
+ · Whisper turbo on CPU · drama3_space_ext.py (prompt layouts)
84
+ · voices and takes in memory
85
+ ```
86
+
87
+ - `server.py` sends requests to the H200 endpoint and relays raw PCM to the browser as it arrives. The endpoint token is a server-side Space secret, never sent to the browser. The stage is a custom Gradio HTML component with the original waveform, take cards, and Web Audio player. Gradio streams a cumulative PCM packet ledger so browser updates cannot drop chunks; audio is queued on one playback clock. A completed WAV appears for replay after the live stream drains. The original full UI remains available at `/legacy`.
88
+ - `drama3_space_ext.py` subclasses the engine's Drama 3 preprocessor. SGLang Omni
89
+ does not let a stage factory be overridden from the command line, so `Dockerfile.engine`
90
+ appends one line to its `fishaudio_drama3/stages.py` (pinned commit) that rebinds the
91
+ class the factory builds. The stock `/v1/audio/speech` builds one prompt
92
+ layout: references, then one line. Drama 3 was also trained on casting a character
93
+ from a description (Figure 13 of the report) and on multi-turn directing with the
94
+ earlier takes in context (Figure 12). The server sends those as a JSON envelope;
95
+ requests without one take the stock path unchanged.
96
+ - Sampling defaults are temperature 1.0, top-p 0.9, and no top-k cap (the server
97
+ sends top-k 4097: end-of-speech plus all 4,096 semantic codes). The SGLang Omni
98
+ default of top-k 30 makes Drama 3 fall silent mid-take: on the H200 endpoint, 40
99
+ takes per setting across the comparison scripts left 11 silent to the length cap
100
+ at top-k 30 (22% word error) and none without the cap (0.5% word error). At a
101
+ pause a few silence codes outrank the many ways speech can resume, and top-k
102
+ keeps only the former. The audio limit scales generously with the spoken script (at least 18 s,
103
+ with extra room for slow directions), up to about 56 s. A take that reaches the
104
+ limit is marked as possibly incomplete.
105
+ - Drama 3 sometimes falls silent mid-line or fails to end the take. The server
106
+ trims leading silence, keeps at most 0.75 s of internal silence and 0.15 s at the
107
+ end, using the existing RMS threshold of 0.003. Four seconds of continuous
108
+ generated silence aborts the request. The first 2.5 s of audio is buffered (short
109
+ completed lines play immediately on completion), so an empty take or a stalled
110
+ opening can be discarded and retried once before playback. Failed openings do
111
+ not count against the daily allowance. This adds a little time before playback
112
+ in exchange for avoiding early broken takes.
113
+ - Completed SSE events close the engine stream immediately. Without audio, a request
114
+ times out after 120 s before the first chunk or 15 s between chunks; each remote take also
115
+ has a 180 s wall-clock limit once the endpoint is ready. Partial stalled or timed-out takes
116
+ stay playable, with an incomplete warning in both studio UIs. These safeguards
117
+ bound bad generations; they cannot guarantee that the model speaks every word.
118
+ - The UI does not poll the endpoint while idle. A generation wakes a sleeping endpoint and waits up to 10 minutes, with progress updates; this preserves its 60-minute scale-to-zero setting. Local GPU mode still warms cloned and unreferenced takes before enabling the page.
119
+
120
+ ## Gradio studio and Workflow
121
+
122
+ The main page uses native Gradio controls, styled to match the original warm neutral
123
+ palette, rounded cards, pill buttons, and colored direction tags. It keeps all script
124
+ and character examples, Clone / Design / Surprise, voice upload and recording,
125
+ automatic transcription, streaming playback and downloads, sampling, take history,
126
+ retakes, and Keep this voice. Design opens with actual Sea captain values, ready to perform without typing. The stage keeps the original player appearance inside `gr.HTML`, with Gradio callbacks for generation, notes, and keeping voices. Cmd/Ctrl+Enter performs a take; the circle button switches
127
+ between light and dark themes.
128
+
129
+ The **Workflow** tab embeds a real `gr.Workflow` app at `/workflow/`. Its starter graph
130
+ connects the script, voice mode (`Clone`, `Design`, or `Surprise`), preset voice id
131
+ (`clone1`, `clone2`, or `clone3`), and voice description to the same generation backend,
132
+ with an audio output. Both views use the existing quotas and admission limits.
133
+ Workflow calls run on the server's event loop so its engine connections and queue are
134
+ shared correctly. The studio exposes `/perform`, `/retake`, and `/keep_voice` through
135
+ the Gradio API; the workflow exposes `/performance`.
136
+
137
+ Gradio requires Workflow to be created at the top level, so it is mounted separately
138
+ and embedded in a tab instead of constructed inside `gr.Blocks`. The parent app signs
139
+ one shared OAuth session. Owners can edit the graph on the canvas after signing in;
140
+ other visitors can run it. Workflow edits change the canvas/API; the custom Studio layout stays fixed.
141
+ The local Drama 3 node works with the existing OAuth permissions. Adding remote
142
+ inference nodes or using Save to Space requires enabling the corresponding
143
+ `inference-api` or `write-repos` OAuth scopes separately.
144
+
145
+ The editable graph is seeded once from `workflow.json` into a writable runtime path.
146
+ Set `WORKFLOW_GRAPH=/data/drama3-workflow.json` to persist edits in the existing storage
147
+ bucket, or leave it unset to use `/tmp/drama3-gradio/workflow.json`. Audio exports are
148
+ kept in the Gradio temp directory, outside the application source. The endpoint keeps its pinned GPU image and SGLang Omni version; Gradio is pinned to **6.28.0**. `Dockerfile` is a small CPU UI image; `Dockerfile.engine` retains the original GPU recipe. Whisper turbo remains available for automatic transcription through faster-whisper int8 on CPU, cached in a Docker layer before the UI source.
149
+
150
+ To preview the UI on a machine without a GPU (no model download or engine launch):
151
+
152
+ ```bash
153
+ python -m venv .venv
154
+ .venv/bin/pip install -r requirements-preview.txt
155
+ DEMO_PREVIEW=1 .venv/bin/python server.py
156
+ ```
157
+
158
+ Open `http://localhost:7860`. The preview loads the real preset voices and supports the
159
+ UI controls; generation explicitly reports that the GPU engine is offline.
160
+ Do not set `DEMO_PREVIEW` for the deployed Space. The original page is retained at
161
+ `/legacy` for visual comparison and rollback.
162
+
163
+ The adapter tests use a simulated engine stream against the real backend logic to
164
+ check streaming, cancellation, quota failures, retakes, and Workflow execution:
165
+
166
+ ```bash
167
+ .venv/bin/pip install pytest
168
+ DEMO_PREVIEW=1 .venv/bin/python -m pytest tests -q
169
+ ```
170
+
171
+ ## Setup for fishaudio/Drama-3
172
+
173
+ This app replaces the previous in-process ZeroGPU demo with the Studio and Workflow
174
+ from [andito/Drama-3](https://huggingface.co/spaces/andito/Drama-3). Speech generation
175
+ uses the existing authenticated **HuggingFaceM4/drama-3-h200** endpoint; its traffic
176
+ and endpoint billing remain with HuggingFaceM4.
177
+
178
+ After merging:
179
+
180
+ 1. In **Space Settings → Hardware**, switch from ZeroGPU to **CPU Basic**.
181
+ 2. In **Space Settings → Variables and secrets**, add **`DRAMA_ENDPOINT_TOKEN` as a
182
+ Secret**. Use the dedicated fine-grained token with permission to make calls to
183
+ Inference Endpoints in **HuggingFaceM4**. Repository access, endpoint management,
184
+ and Inference Providers permissions are not needed.
185
+ 3. Restart the Space after configuring the hardware and Secret, if it has not
186
+ already restarted automatically.
187
+
188
+ The endpoint URL is already configured in `Dockerfile`; setting a
189
+ `DRAMA_ENDPOINT_URL` variable is optional. The README's `hf_oauth: true` enables HF
190
+ sign-in, and Hugging Face supplies its OAuth credentials automatically. No manual
191
+ OAuth client or GPU model preload is required. The endpoint token stays on the
192
+ server and is never sent to the browser.
193
+
194
+ No persistent storage is required. With the default temporary storage, usage counts
195
+ and saved Workflow edits reset when the Space restarts. Storage and a stable
196
+ `DEMO_USAGE_HASH_SECRET` are optional settings described below.
197
+
198
+ ## Settings
199
+
200
+ The deployed UI requires `DRAMA_ENDPOINT_TOKEN` as a **Space secret** with access to the endpoint. The URL is configured in the CPU Docker image and can be overridden with `DRAMA_ENDPOINT_URL`. Other settings are optional:
201
+
202
+ | Variable | Default | What it does |
203
+ |---|---|---|
204
+ | `DRAMA_ENDPOINT_URL` | H200 endpoint URL | Remote speech server; omit outside the CPU Docker image for local GPU mode |
205
+ | `DRAMA_ENDPOINT_TOKEN` | | Server-side authentication secret for the endpoint |
206
+ | `ENDPOINT_WAKE_TIMEOUT_S` | `600` | Maximum wait for a sleeping endpoint |
207
+ | `WHISPER_MODEL` | `turbo` | Automatic reference transcription model |
208
+ | `WHISPER_CPU_THREADS` | `2` | CPU transcription threads |
209
+ | `DEMO_MAX_ACTIVE` | `12` | Streams sent to the engine at once; the rest queue |
210
+ | `DEMO_MAX_QUEUE` | `64` | Queue length before new requests are turned away |
211
+ | `DEMO_GUEST_DAILY_REQUESTS` | `5` | Takes per UTC day without signing in |
212
+ | `DEMO_DAILY_FREE_REQUESTS` | `20` | Takes per UTC day for signed-in free accounts |
213
+ | `DEMO_REQUIRE_LOGIN` | `0` | Set to `1` to require sign-in for everything |
214
+ | `DEMO_UNLIMITED_ORGS` | `fishaudio` | Orgs without a daily cap, besides HuggingFaceM4 |
215
+ | `DEMO_USAGE_HASH_SECRET` | | Secret for the account keys in the usage database |
216
+ | `DEMO_PER_CLIENT_ACTIVE` | `2` | Concurrent takes per account |
217
+ | `DEMO_PER_CLIENT_REQUESTS` | `60` | Takes per account per `DEMO_RATE_WINDOW_S` (600 s), PRO included |
218
+ | `ENGINE_MAX_RUNNING` | `16` | `--tts_engine.factory.max_running_requests` |
219
+ | `ENGINE_EXTRA_ARGS` | | Extra `sgl-omni serve` flags, e.g. `--tts_engine.gpu_memory_fraction 0.8` |
220
+
221
+ The CPU UI does not preload the speech model. The endpoint owns its weights and CUDA graphs. See [endpoint/README.md](endpoint/README.md) for the deployment scripts, pinned model/image, and native API. To run the model inside a GPU container instead, build with `docker build -f Dockerfile.engine .` and leave `DRAMA_ENDPOINT_URL` unset.
222
+
223
+ ## Run the full model yourself
224
 
225
  - [Fish Audio Drama 3 cookbook](https://sgl-project.github.io/sglang-omni/cookbook/fishaudio_drama3.html): install, voice cloning, and the HTTP API
226
  - [SGLang Omni](https://github.com/sgl-project/sglang-omni): the GitHub repo — please give it a star
227
+ - [Technical report](https://arxiv.org/abs/2610.18010): architecture of the 35B MoE model and its control syntax
228
  - [Fish Audio Playground](https://fish.audio): try Fish Audio voices in the browser
229
  - [Fish Speech](https://github.com/fishaudio/fish-speech): source repository for the model
230
 
 
 
 
 
 
 
 
 
 
 
231
  ## Acknowledgments
232
 
233
  It took us over half a year to bring SGLang Omni back to life. Thanks to all the people who helped us along the way.
234
 
235
+ Thanks so much to [andito](https://huggingface.co/andito), [AK](https://x.com/_akhaliq), and [poli](https://huggingface.co/multimodalart) for helping set up the Space.
236
 
237
  **SGLang Omni team:** [Chenyang Zhao](https://x.com/GenAI_is_real), Jingwen Gu, Shenggui Li, Richard Wang, Yichi Zhang, Xuesong Ye, Jiaxin Deng, Ratish P, Jiaxuan Luo, Xinyu Lu, Wenyao Gao, Ao Sun, Jintao Quaige Li, Yuhao Chen, Yuhan Zhou, Xuxiang Tian, Ruoyi Zhang, Junnan Li, Yixiao Zeng, Chenchen Hong, Huapeng Zhou, Yang Gao, Chaoye Fei, Yiyang Zh, Kang, Yitian Gong, Yuan Luo, Hao Jin, Yifei Gao, Gaokai Zhang, Lifan Shen, Hui Shen, Kejing Xia, Jinxin Li, Dongming Shen, Jun Liu, Yue Yin, Zhihao Guo, Jisen Li, Ruoyu He, Xuanyi Li, Chenye Zhu, Liang Zhen, Xiaodong Ye, Yizhi Zhen, Kaizuo Li, Chonghan Liu, Wei Guan, Zhuofeng Li
238
 
239
  **FishAudio Team:** Shijia Liao, Shidong Li, Xuehao Yang, Jiahua Liu
240
 
241
+ ## License
242
 
243
+ Model license: [Fish Audio Community License](https://huggingface.co/fishaudio/drama-3/blob/main/LICENSE)
244
+ — free non-commercial/research demo. Built with Fish Audio. Only clone voices you have
245
+ permission to use.
 
accounts.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Who is asking, and how many takes they have left today.
2
+
3
+ Guests can perform DEMO_GUEST_DAILY_REQUESTS takes per UTC day without signing in.
4
+ Signing in with Hugging Face gives free accounts DEMO_DAILY_FREE_REQUESTS more, and
5
+ PRO accounts, members of Team or Enterprise orgs, and members of the allow-listed
6
+ orgs have no daily cap (entitlements.py).
7
+
8
+ As in the faster-qwen3-tts demo, usage is stored under HMACs, never raw ids, in
9
+ SQLite on an attached storage bucket (USAGE_BUCKET_MOUNT_PATH, /data) when one is
10
+ mounted, else in /tmp. A signed-in account is keyed by its stable id. A guest is
11
+ keyed by both its address and a session cookie, and the higher count applies; the
12
+ Hub's proxy passes a client-supplied X-Forwarded-For through, so a determined guest
13
+ can dodge this, which is why the engine's own concurrency limits stay in place.
14
+
15
+ Scripts can identify with a Hugging Face token (X-HF-Token, or a Bearer token)
16
+ instead of the browser session; the account's quota applies the same way.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import hmac
23
+ import os
24
+ import secrets
25
+ import sqlite3
26
+ import threading
27
+ import time
28
+ from dataclasses import dataclass
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+ from types import SimpleNamespace
32
+
33
+ from fastapi import HTTPException, Request
34
+ from huggingface_hub import parse_huggingface_oauth
35
+
36
+ from entitlements import Entitlement, _whoami_via_token, resolve_entitlement
37
+
38
+
39
+ def env_flag(name: str, default: str = "0") -> bool:
40
+ return os.environ.get(name, default).strip().lower() not in {"0", "false", "no", "off", ""}
41
+
42
+
43
+ REQUIRE_LOGIN = env_flag("DEMO_REQUIRE_LOGIN", "0")
44
+ GUEST_DAILY_REQUESTS = int(os.environ.get("DEMO_GUEST_DAILY_REQUESTS", "5"))
45
+ DAILY_FREE_REQUESTS = int(os.environ.get("DEMO_DAILY_FREE_REQUESTS", "20"))
46
+ USAGE_BUCKET_MOUNT_PATH = Path(os.environ.get("USAGE_BUCKET_MOUNT_PATH", "/data"))
47
+ USAGE_DB_FILENAME = os.environ.get("USAGE_DB_FILENAME", "drama3-usage.sqlite3")
48
+
49
+
50
+ def default_usage_db_path() -> Path:
51
+ if USAGE_BUCKET_MOUNT_PATH.exists() and os.access(USAGE_BUCKET_MOUNT_PATH, os.W_OK):
52
+ return USAGE_BUCKET_MOUNT_PATH / USAGE_DB_FILENAME
53
+ return Path("/tmp/drama3-space") / "usage.sqlite3"
54
+
55
+
56
+ USAGE_DB_PATH = Path(os.environ.get("USAGE_DB_PATH", str(default_usage_db_path())))
57
+ # Set DEMO_USAGE_HASH_SECRET as a Space secret when usage persists in a bucket;
58
+ # without it, keys stay private but change on every restart.
59
+ _HASH_SECRET = (os.environ.get("DEMO_USAGE_HASH_SECRET") or secrets.token_hex(32)).encode()
60
+ _db_lock = threading.Lock()
61
+ _db_ready = False
62
+
63
+ GUEST = Entitlement(tier="guest", is_pro=False, is_team_member=False)
64
+ LOCAL = Entitlement(tier="team", is_pro=False, is_team_member=True)
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Identity:
69
+ """keys[0] identifies the caller for concurrency limits; usage counts all keys."""
70
+
71
+ keys: tuple[str, ...]
72
+ name: str
73
+ entitlement: Entitlement
74
+ signed_in: bool
75
+
76
+ @property
77
+ def key(self) -> str:
78
+ return self.keys[0]
79
+
80
+ @property
81
+ def daily_limit(self) -> int | None:
82
+ if self.entitlement.unlimited:
83
+ return None
84
+ return DAILY_FREE_REQUESTS if self.signed_in else GUEST_DAILY_REQUESTS
85
+
86
+
87
+ def hashed(kind: str, value: str) -> str:
88
+ digest = hmac.new(_HASH_SECRET, f"{kind}:{value}".encode("utf-8"), hashlib.sha256).hexdigest()
89
+ return f"{kind}_{digest}"
90
+
91
+
92
+ def request_token(request: Request) -> str | None:
93
+ token = request.headers.get("x-hf-token", "").strip()
94
+ if not token:
95
+ scheme, _, value = request.headers.get("authorization", "").partition(" ")
96
+ token = value.strip() if scheme.lower() == "bearer" else ""
97
+ return token if token.startswith("hf_") else None
98
+
99
+
100
+ def signed_in_identity(request: Request) -> Identity | None:
101
+ """The account behind the browser session or a Hugging Face token, if any."""
102
+ oauth_info = parse_huggingface_oauth(request)
103
+ user = getattr(oauth_info, "user_info", None) if oauth_info is not None else None
104
+ if user is None:
105
+ token = request_token(request)
106
+ profile = _whoami_via_token(token) if token else {}
107
+ if not profile.get("id"):
108
+ return None
109
+ user = SimpleNamespace(
110
+ sub=profile["id"],
111
+ preferred_username=profile.get("name"),
112
+ is_pro=profile.get("isPro", False),
113
+ orgs=profile.get("orgs", []),
114
+ )
115
+ oauth_info = SimpleNamespace(user_info=user, access_token=token)
116
+ account_id = str(getattr(user, "sub", None) or getattr(user, "preferred_username", "") or "")
117
+ if not account_id:
118
+ return None
119
+ name = str(getattr(user, "preferred_username", None) or getattr(user, "name", None) or account_id)
120
+ return Identity(
121
+ keys=(hashed("hfuser", account_id),),
122
+ name=name,
123
+ entitlement=resolve_entitlement(oauth_info),
124
+ signed_in=True,
125
+ )
126
+
127
+
128
+ def guest_identity(request: Request) -> Identity:
129
+ forwarded = request.headers.get("x-forwarded-for", "")
130
+ address = forwarded.split(",", 1)[0].strip() if forwarded else (request.client.host if request.client else "")
131
+ keys = [hashed("guestip", address or "unknown")]
132
+ try:
133
+ session = request.session
134
+ except AssertionError: # no session middleware, e.g. when run outside a Space
135
+ session = None
136
+ if session is not None:
137
+ session.setdefault("guest", secrets.token_urlsafe(18))
138
+ keys.append(hashed("guest", session["guest"]))
139
+ return Identity(keys=tuple(keys), name="guest", entitlement=GUEST, signed_in=False)
140
+
141
+
142
+ def identify(request: Request) -> Identity:
143
+ identity = signed_in_identity(request)
144
+ if identity is not None:
145
+ return identity
146
+ if REQUIRE_LOGIN:
147
+ raise HTTPException(401, "Sign in with Hugging Face to use this demo.")
148
+ if not os.environ.get("SPACE_ID"):
149
+ # Running locally: no quotas.
150
+ return Identity(keys=("local",), name="local", entitlement=LOCAL, signed_in=False)
151
+ return guest_identity(request)
152
+
153
+
154
+ # ─── Daily usage ──────────────────────────────────────────────────────────────
155
+
156
+
157
+ def today() -> str:
158
+ return datetime.now(timezone.utc).date().isoformat()
159
+
160
+
161
+ def _connect() -> sqlite3.Connection:
162
+ global _db_ready
163
+ if not _db_ready:
164
+ USAGE_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
165
+ connection = sqlite3.connect(USAGE_DB_PATH, timeout=30)
166
+ if not _db_ready:
167
+ connection.execute(
168
+ """CREATE TABLE IF NOT EXISTS usage_daily (
169
+ user_key TEXT NOT NULL, day TEXT NOT NULL,
170
+ is_pro INTEGER NOT NULL DEFAULT 0, count INTEGER NOT NULL DEFAULT 0,
171
+ updated_at INTEGER NOT NULL, PRIMARY KEY (user_key, day))"""
172
+ )
173
+ connection.execute(
174
+ """CREATE TABLE IF NOT EXISTS usage_users (
175
+ user_key TEXT PRIMARY KEY, username TEXT NOT NULL,
176
+ is_pro INTEGER NOT NULL DEFAULT 0,
177
+ first_seen_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL)"""
178
+ )
179
+ _db_ready = True
180
+ return connection
181
+
182
+
183
+ def _count(connection: sqlite3.Connection, identity: Identity, day: str) -> int:
184
+ counts = [0]
185
+ for key in identity.keys:
186
+ row = connection.execute(
187
+ "SELECT count FROM usage_daily WHERE user_key = ? AND day = ?", (key, day)
188
+ ).fetchone()
189
+ counts.append(int(row[0]) if row else 0)
190
+ return max(counts)
191
+
192
+
193
+ def _record_user(connection: sqlite3.Connection, identity: Identity, now: int) -> None:
194
+ if not identity.signed_in:
195
+ return
196
+ connection.execute(
197
+ """INSERT INTO usage_users (user_key, username, is_pro, first_seen_at, last_seen_at)
198
+ VALUES (?, ?, ?, ?, ?)
199
+ ON CONFLICT(user_key) DO UPDATE SET username = excluded.username,
200
+ is_pro = excluded.is_pro, last_seen_at = excluded.last_seen_at""",
201
+ (identity.key, identity.name, int(identity.entitlement.is_pro), now, now),
202
+ )
203
+
204
+
205
+ def usage_payload(identity: Identity, count: int) -> dict:
206
+ limit = identity.daily_limit
207
+ return {
208
+ "used_today": count,
209
+ "limit": limit,
210
+ "remaining": None if limit is None else max(0, limit - count),
211
+ "tier": identity.entitlement.tier,
212
+ "unlimited": limit is None,
213
+ "signed_in": identity.signed_in,
214
+ "free_after_sign_in": DAILY_FREE_REQUESTS,
215
+ }
216
+
217
+
218
+ def usage(identity: Identity) -> dict:
219
+ with _db_lock, _connect() as connection:
220
+ _record_user(connection, identity, int(time.time()))
221
+ return usage_payload(identity, _count(connection, identity, today()))
222
+
223
+
224
+ def check_quota(identity: Identity) -> None:
225
+ limit = identity.daily_limit
226
+ if limit is None:
227
+ return
228
+ with _db_lock, _connect() as connection:
229
+ if _count(connection, identity, today()) < limit:
230
+ return
231
+ if identity.signed_in:
232
+ raise HTTPException(
233
+ 429,
234
+ f"That's your {limit} free takes for today. Hugging Face PRO accounts have "
235
+ "unlimited takes; the free ones come back at midnight UTC.",
236
+ )
237
+ raise HTTPException(
238
+ 429,
239
+ f"That's the {limit} takes guests get each day. Sign in with Hugging Face for "
240
+ f"{DAILY_FREE_REQUESTS} more.",
241
+ )
242
+
243
+
244
+ def consume_take(identity: Identity) -> dict:
245
+ """Count one take for today under every key, and return the updated usage."""
246
+ day, now = today(), int(time.time())
247
+ with _db_lock, _connect() as connection:
248
+ _record_user(connection, identity, now)
249
+ count = _count(connection, identity, day) + 1
250
+ for key in identity.keys:
251
+ connection.execute(
252
+ """INSERT INTO usage_daily (user_key, day, is_pro, count, updated_at)
253
+ VALUES (?, ?, ?, ?, ?)
254
+ ON CONFLICT(user_key, day) DO UPDATE SET is_pro = excluded.is_pro,
255
+ count = excluded.count, updated_at = excluded.updated_at""",
256
+ (key, day, int(identity.entitlement.is_pro), count, now),
257
+ )
258
+ return usage_payload(identity, count)
259
+
260
+
261
+ def public_user(identity: Identity) -> dict:
262
+ return {"username": identity.name, "tier": identity.entitlement.tier, "signed_in": identity.signed_in}
app.py DELETED
@@ -1,776 +0,0 @@
1
- """Single-speaker Drama 3 voice cloning demo for Hugging Face ZeroGPU.
2
-
3
- Inputs: reference audio, its exact transcript, and the text to synthesize.
4
- The UI is the fishaudio team's hand-built Blocks page, mounted inside the app
5
- view of a `gr.Workflow` — the built-in toggle switches between this page and
6
- the Workflow canvas (workflow.json), which runs the same single-speaker
7
- pipeline through the `drama3_clone` node. Keep fish_speech/, requirements.txt,
8
- and the README SDK configuration.
9
-
10
- Inference is the official PyTorch path of fishaudio/drama-3, vendored under
11
- fish_speech/. It runs single-process on a ZeroGPU `large` slice (RTX Pro 6000,
12
- 48 GB): the 32.2B FP8 MoE experts stay in FP8 — dequantised to bf16 they would
13
- need ~67 GB. Modules are built on the meta device, materialised on CPU with FP8
14
- experts, then moved to "cuda", because the FP8 path needs an SM89+ GPU at load
15
- time and ZeroGPU has no GPU at import.
16
-
17
- Requires the Space hardware to be set to ZeroGPU (Settings -> Hardware).
18
- """
19
-
20
- import os
21
-
22
- os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
23
-
24
- # Import spaces before any module initializes CUDA. Model loading stays at
25
- # module scope so ZeroGPU can pack the weights during startup.
26
- try:
27
- import spaces
28
- except ImportError:
29
- class _SpacesShim:
30
- @staticmethod
31
- def GPU(duration=60):
32
- return lambda fn: fn
33
-
34
- spaces = _SpacesShim()
35
-
36
- import difflib
37
- import json
38
- import random
39
- import re
40
- import time
41
- import urllib.parse
42
- from pathlib import Path
43
-
44
- import gradio as gr
45
- import librosa
46
- import numpy as np
47
- import soundfile as sf
48
- import torch
49
- from gradio_client import utils as client_utils
50
- from tokenizers import Tokenizer
51
-
52
- from fish_speech.codec import decode as codec_decode
53
- from fish_speech.codec import encode as codec_encode
54
- from fish_speech.codec import load_codec
55
- from fish_speech.models.text2semantic.errors import InvalidInputError
56
- from fish_speech.models.text2semantic.fish_omni.backbone import (
57
- CHECKPOINT_BACKBONE_PREFIX,
58
- CHECKPOINT_LM_HEAD_KEY,
59
- FishOmniBackbone,
60
- load_backbone_weights,
61
- )
62
- from fish_speech.models.text2semantic.fish_omni.chunking import (
63
- Drama3History,
64
- split_drama3_text,
65
- )
66
- from fish_speech.models.text2semantic.fish_omni.config import FishOmniConfig
67
- from fish_speech.models.text2semantic.fish_omni.generate import (
68
- Drama3DecodeGraphs,
69
- Drama3PromptTooLongError,
70
- build_drama3_prompt,
71
- generate_drama3,
72
- )
73
- from fish_speech.models.text2semantic.fish_omni.model import (
74
- AUDIO_DECODER_PREFIXES,
75
- FishOmniModel,
76
- iter_checkpoint_tensors,
77
- )
78
- from fish_speech.models.text2semantic.fish_omni.tower import (
79
- FishOmniAudioDecoder,
80
- load_audio_decoder_weights,
81
- )
82
-
83
- MODEL_ID = "fishaudio/drama-3"
84
- DTYPE = torch.bfloat16
85
- SAMPLE_RATE = 44100
86
- MAX_REF_SECONDS = 30
87
- MAX_SCRIPT_CHARS = 3000
88
- MAX_SEED = 2**31 - 1
89
- # Sampling values used by the SGLang-Omni Drama 3 Cookbook's serving examples.
90
- # These are model-generation parameters, independent of the UI/runtime backend.
91
- TEMPERATURE = 1.0
92
- TOP_P = 0.9
93
- TOP_K = 30
94
- MAX_NEW_TOKENS = 500
95
- COOKBOOK_URL = "https://sgl-project.github.io/sglang-omni/cookbook/fishaudio_drama3.html"
96
- SGLANG_OMNI_URL = "https://github.com/sgl-project/sglang-omni"
97
- TECHNICAL_REPORT_URL = (
98
- "https://github.com/fishaudio/fish-speech/tree/main/docs/assets/"
99
- "Fish_Audio_Drama_3_Technical_Report.pdf"
100
- )
101
- FISH_AUDIO_URL = "https://fish.audio"
102
- FISH_SPEECH_URL = "https://github.com/fishaudio/fish-speech"
103
- MODEL_URL = "https://huggingface.co/fishaudio/drama-3"
104
- AK_URL = "https://x.com/_akhaliq"
105
- CHENYANG_URL = "https://x.com/GenAI_is_real"
106
- SHORT_SPEAKER = re.compile(r"^\s*(?:\[S(\d+)\]|S(\d+)\s*:)\s*", re.M)
107
- SPEAKER_TAG = re.compile(r"<\|speaker:(\d+)\|>")
108
-
109
-
110
- def speaker_tag(index=0):
111
- return "<" + f"|speaker:{index}|>"
112
-
113
-
114
- # Preserve the original FP8 model-loading path. On CPU the page opens without
115
- # attempting to load the checkpoint; generation requires ZeroGPU or CUDA.
116
- print(f"[load] {MODEL_ID} ...", flush=True)
117
- if torch.cuda.is_available():
118
- from huggingface_hub import snapshot_download
119
-
120
- checkpoint_dir = snapshot_download(MODEL_ID)
121
- with open(os.path.join(checkpoint_dir, "config.json"), encoding="utf-8") as f:
122
- config = FishOmniConfig.from_dict(json.load(f))
123
- with torch.device("meta"):
124
- backbone = FishOmniBackbone(
125
- config.text, fp8_experts=config.fp8_experts, dtype=DTYPE
126
- )
127
- audio_decoder = FishOmniAudioDecoder(config, dtype=DTYPE)
128
- backbone.materialize(["cpu"])
129
- load_backbone_weights(
130
- backbone,
131
- iter_checkpoint_tensors(
132
- Path(checkpoint_dir), skip_prefixes=AUDIO_DECODER_PREFIXES
133
- ),
134
- )
135
- audio_decoder.to_empty(device="cpu")
136
- load_audio_decoder_weights(
137
- audio_decoder,
138
- iter_checkpoint_tensors(
139
- Path(checkpoint_dir),
140
- skip_prefixes=(CHECKPOINT_BACKBONE_PREFIX, CHECKPOINT_LM_HEAD_KEY),
141
- ),
142
- )
143
- tokenizer = Tokenizer.from_file(os.path.join(checkpoint_dir, "tokenizer.json"))
144
- model = FishOmniModel(config, backbone, audio_decoder, tokenizer).eval()
145
- model.to("cuda")
146
- codec = load_codec(os.path.join(checkpoint_dir, "codec.pth"), DTYPE).to("cuda")
147
- else:
148
- model = None
149
- codec = None
150
- print("[load] ready", flush=True)
151
-
152
-
153
- # ---------------------------------------------------------------------------
154
- # Canvas value adapters (used by the workflow node only)
155
- # ---------------------------------------------------------------------------
156
- def to_audio_path(value):
157
- """Normalize whatever the canvas audio port sends to a local path (or None).
158
-
159
- Canvas file values carry only a url (FileValue has no path): same-server
160
- /gradio_api/file= urls resolve to local paths, http(s) urls are fetched.
161
- (sample_rate, array) tuples are written to a temp wav.
162
- """
163
- if value is None:
164
- return None
165
- if isinstance(value, tuple) and len(value) == 2:
166
- from gradio.utils import get_upload_folder
167
-
168
- sr, data = value
169
- directory = get_upload_folder()
170
- os.makedirs(directory, exist_ok=True)
171
- path = os.path.join(directory, f"in_{os.urandom(8).hex()}.wav")
172
- sf.write(path, np.asarray(data), int(sr))
173
- return path
174
- if isinstance(value, str):
175
- src = value
176
- elif isinstance(value, dict):
177
- src = value.get("path") or value.get("url")
178
- else:
179
- return None
180
- if not isinstance(src, str) or not src:
181
- return None
182
- if src.startswith("/gradio_api/file="):
183
- src = urllib.parse.unquote(src.removeprefix("/gradio_api/file="))
184
- if src.startswith(("http://", "https://")):
185
- import httpx
186
-
187
- from gradio.utils import get_upload_folder
188
-
189
- resp = httpx.get(src, timeout=60, follow_redirects=True)
190
- resp.raise_for_status()
191
- directory = get_upload_folder()
192
- os.makedirs(directory, exist_ok=True)
193
- path = os.path.join(directory, f"in_{os.urandom(8).hex()}.wav")
194
- with open(path, "wb") as f:
195
- f.write(resp.content)
196
- return path
197
- return src if os.path.exists(src) else None
198
-
199
-
200
- def to_audio_media(sample_rate, audio):
201
- """Save an int16 waveform and return the media dict the canvas renders.
202
-
203
- Saved into gradio's upload folder (as gradio's own workflow code does),
204
- which is served without an allowlist entry.
205
- """
206
- from gradio.utils import get_upload_folder
207
-
208
- directory = get_upload_folder()
209
- os.makedirs(directory, exist_ok=True)
210
- path = os.path.join(directory, f"drama3_{os.urandom(8).hex()}.wav")
211
- sf.write(path, audio, sample_rate)
212
- return {
213
- "path": path,
214
- "url": f"/gradio_api/file={client_utils.encode_file_path(path)}",
215
- "is_file": True,
216
- }
217
-
218
-
219
- # ---------------------------------------------------------------------------
220
- # Demo logic (unchanged from the fishaudio team's single-speaker version)
221
- # ---------------------------------------------------------------------------
222
- def normalize_script(text):
223
- """Accept plain text and bind it to the single reference speaker."""
224
- text = (text or "").replace("\r\n", "\n").strip()
225
- if not text:
226
- raise gr.Error("Enter the text you want to generate.")
227
- text = SHORT_SPEAKER.sub(
228
- lambda m: speaker_tag(m.group(1) or m.group(2)) + " ", text
229
- )
230
- if any(int(index) != 0 for index in SPEAKER_TAG.findall(text)):
231
- raise gr.Error("This demo supports one voice. Enter a single speaker's text.")
232
- if not text.startswith(speaker_tag()):
233
- text = speaker_tag() + " " + text
234
- if len(text) > MAX_SCRIPT_CHARS:
235
- raise gr.Error(f"Keep the text under {MAX_SCRIPT_CHARS} characters.")
236
- return text
237
-
238
-
239
- def load_reference(path):
240
- """Validate the entire clip without silently dropping transcript content."""
241
- try:
242
- wav, _ = librosa.load(path, sr=SAMPLE_RATE, mono=True)
243
- except Exception as err:
244
- raise gr.Error("Could not read the reference audio. Try uploading a WAV file.") from err
245
- if len(wav) < SAMPLE_RATE // 2:
246
- raise gr.Error("Record or upload at least half a second of reference speech.")
247
- if len(wav) > MAX_REF_SECONDS * SAMPLE_RATE:
248
- raise gr.Error("Use a reference clip of 30 seconds or less, with its matching transcript.")
249
- if not np.isfinite(wav).all():
250
- raise gr.Error("The reference audio contains invalid samples. Use another recording.")
251
- peak = np.abs(wav).max()
252
- if peak == 0:
253
- raise gr.Error("The reference audio is silent. Record a spoken sentence.")
254
- wav = wav * min(1.0, 0.95 / peak)
255
- return torch.from_numpy(wav.astype(np.float32))
256
-
257
-
258
- def build_chunk_prompt(text, ref_texts, ref_codes, history):
259
- error = None
260
- for turns in history.candidates():
261
- try:
262
- return build_drama3_prompt(
263
- text, tokenizer, config, ref_texts, ref_codes, history=turns
264
- )
265
- except Drama3PromptTooLongError as too_long:
266
- error = too_long
267
- if error is not None:
268
- raise error
269
- raise gr.Error("Could not build the speech prompt. Try shorter text and audio.")
270
-
271
-
272
- def estimate_duration(script, reference_waveform, reference_text):
273
- # Keep the original duration estimate and 120-second request cap.
274
- chunks = max(1, -(-len(script.encode()) // 300))
275
- return int(min(120, 8 + chunks * 19 + 4))
276
-
277
-
278
- @spaces.GPU(duration=estimate_duration)
279
- def _gpu_synthesize(script, reference_waveform, reference_text):
280
- """One synthesis call; the reference is conditioning, never a prior take."""
281
- started = time.perf_counter()
282
- with torch.inference_mode():
283
- seed = int(np.random.randint(0, MAX_SEED))
284
- torch.manual_seed(seed)
285
- np.random.seed(seed)
286
- ref_texts = [speaker_tag() + " " + reference_text]
287
- ref_codes = [codec_encode(codec, reference_waveform).cpu()]
288
- chunks = split_drama3_text(script) or [script]
289
- capacity = Drama3DecodeGraphs.graph_capacity(model)
290
- history = Drama3History()
291
- pieces = []
292
- finished_all = True
293
- try:
294
- for chunk in chunks:
295
- prompt = build_chunk_prompt(chunk, ref_texts, ref_codes, history)
296
- budget = min(MAX_NEW_TOKENS, capacity - len(prompt))
297
- if budget < 1:
298
- raise gr.Error("The reference and text are too long. Try a shorter clip.")
299
- frames, finish = generate_drama3(
300
- model,
301
- prompt,
302
- max_new_tokens=budget,
303
- temperature=TEMPERATURE,
304
- top_k=TOP_K,
305
- top_p=TOP_P,
306
- )
307
- finished_all &= finish == "eos"
308
- if len(frames):
309
- codes = frames[:, 1:].T.contiguous()
310
- pieces.append(codes)
311
- history.push(chunk, codes)
312
- except (InvalidInputError, Drama3PromptTooLongError) as err:
313
- raise gr.Error(str(err)) from err
314
- if not pieces:
315
- raise gr.Error("No speech was generated. Click Generate to try again.")
316
- codes = torch.cat(pieces, dim=1)
317
- audio = codec_decode(codec, codes).float().cpu().numpy()
318
- audio = np.clip(audio, -1.0, 1.0)
319
- if not finished_all:
320
- gr.Warning("The generated audio may be cut short. Try shorter text.")
321
- elapsed = time.perf_counter() - started
322
- seconds = len(audio) / SAMPLE_RATE
323
- info = (
324
- f"seed {seed} | {seconds:.1f}s of audio in {elapsed:.1f}s | "
325
- f"chunks: {len(chunks)}"
326
- )
327
- return (SAMPLE_RATE, (audio * 32767).astype(np.int16)), seed, info
328
-
329
-
330
- def synthesize(reference_audio, reference_text, text_to_generate, consent_verified):
331
- # The consent gate is enforced here, not just in the UI.
332
- if not consent_verified:
333
- raise gr.Error(
334
- "Complete the voice consent gate first: generate a sentence, record "
335
- "yourself reading it, and verify."
336
- )
337
- # Validate on CPU before requesting scarce ZeroGPU time.
338
- if not reference_audio:
339
- raise gr.Error("Record or upload reference audio first.")
340
- reference_text = (reference_text or "").strip()
341
- if not reference_text:
342
- raise gr.Error("Enter exactly what is spoken in the reference audio.")
343
- script = normalize_script(text_to_generate)
344
- if model is None:
345
- raise gr.Error("Generation requires GPU hardware. Set this Space to ZeroGPU and restart it.")
346
- waveform = load_reference(reference_audio)
347
- (sr_audio, _, _info) = _gpu_synthesize(script, waveform, reference_text)
348
- return sr_audio
349
-
350
-
351
- def drama3_clone(reference_audio, reference_text, text_to_generate, consent_confirmed=False):
352
- """Workflow node: the same single-speaker pipeline, callable from the canvas.
353
-
354
- Returns (audio media dict, seed, run-info text).
355
- """
356
- if not consent_confirmed:
357
- raise gr.Error(
358
- "Tick the consent box: only clone a voice whose speaker explicitly agreed."
359
- )
360
- if not reference_audio:
361
- raise gr.Error("Record or upload reference audio first.")
362
- reference_text = (reference_text or "").strip()
363
- if not reference_text:
364
- raise gr.Error("Enter exactly what is spoken in the reference audio.")
365
- script = normalize_script(text_to_generate)
366
- if model is None:
367
- raise gr.Error("Generation requires GPU hardware. Set this Space to ZeroGPU and restart it.")
368
- waveform = load_reference(to_audio_path(reference_audio) or reference_audio)
369
- (sr, audio), seed, info = _gpu_synthesize(script, waveform, reference_text)
370
- return to_audio_media(sr, audio), seed, info
371
-
372
-
373
- # ---------------------------------------------------------------------------
374
- # Voice consent gate (https://huggingface.co/blog/voice-consent-gate): a voice
375
- # is cloned only after the speaker reads a unique consent sentence aloud and an
376
- # ASR check recognizes it. The same recording then serves as the reference
377
- # audio — spoken consent and cloning input in one.
378
- # ---------------------------------------------------------------------------
379
- CONSENT_MODEL_NAME = "Drama 3"
380
- CONSENT_ASR_ID = "openai/whisper-base.en"
381
- CONSENT_THRESHOLD = 0.85
382
- CONSENT_BLOG_URL = "https://huggingface.co/blog/voice-consent-gate"
383
- LLAMA_SPACE_ID = os.getenv("LLAMA_SPACE_ID", "huggingface-projects/llama-3.2-3B-Instruct")
384
- LLAMA_API_NAME = "/chat"
385
- CONSENT_TOPICS = [
386
- "the weather", "food", "music", "a morning walk", "coffee", "reading",
387
- "gardening", "the weekend", "a favorite movie", "cooking dinner",
388
- "public transport", "a local park",
389
- ]
390
- # Used only if the sentence-generation Space is unreachable; the LLM path is
391
- # what makes each session's sentence novel.
392
- CONSENT_FALLBACK_SENTENCES = [
393
- "I give my consent to use my voice for generating audio with the model Drama 3. The weather is bright and calm this morning.",
394
- "I give my consent to use my voice for generating audio with the model Drama 3. The coffee shop outside has a pleasant aroma this morning.",
395
- "I agree to the use of my recorded voice for audio generation with the model Drama 3. After a gentle walk, I am feeling relaxed and ready to speak.",
396
- "I give my consent to use my voice for generating synthetic audio with Drama 3 today. My daily routine keeps me out and about most days.",
397
- ]
398
-
399
-
400
- def get_consent_generation_prompt(topic: str) -> str:
401
- return f"""Generate exactly two short, natural-sounding English sentences (10-15 words each) that a person could say aloud, using everyday language.
402
-
403
- Sentence 1 (Consent sentence):
404
- * Clearly states informed consent to use their voice for generating synthetic audio with an AI model called {CONSENT_MODEL_NAME}.
405
- * Must explicitly include a consent phrase such as "I give my consent," "I agree," or "I allow."
406
- * Must clearly mention the model name {CONSENT_MODEL_NAME} in the sentence.
407
- * Should sound fluent, polite, and natural to read aloud.
408
-
409
- Sentence 2 (Phonetic variety sentence):
410
- * Should not repeat the consent content.
411
- * Adds phonetic variety with a neutral descriptive clause, for example about {topic}.
412
- * Should be fluent, natural, and comfortable to read aloud, with diverse vowels and consonants.
413
-
414
- FORMAT:
415
- * Output EXACTLY two sentences.
416
- * No numbering, no quotes, no bullet points, and no introductory text.
417
-
418
- Example format (don't copy text, just the format):
419
- I give my consent to use my voice for generating audio with the model {CONSENT_MODEL_NAME}. The weather is clear and calm this afternoon, and I'm speaking at an even pace."""
420
-
421
-
422
- def generate_consent_sentence():
423
- """A novel consent sentence pair per session (small LLM, local fallback)."""
424
- topic = random.choice(CONSENT_TOPICS)
425
- try:
426
- from gradio_client import Client
427
-
428
- client = Client(LLAMA_SPACE_ID)
429
- result = client.predict(
430
- message=get_consent_generation_prompt(topic),
431
- max_new_tokens=128,
432
- temperature=0.6,
433
- top_p=0.9,
434
- top_k=50,
435
- repetition_penalty=1.2,
436
- api_name=LLAMA_API_NAME,
437
- )
438
- text = " ".join(str(result).split())
439
- if CONSENT_MODEL_NAME in text and len(text.split()) >= 8:
440
- return text
441
- except Exception as err:
442
- print(f"[consent] LLM sentence generation failed, using fallback: {err!r}", flush=True)
443
- return random.choice(CONSENT_FALLBACK_SENTENCES)
444
-
445
-
446
- _consent_asr = None
447
-
448
-
449
- def _load_consent_asr():
450
- """Whisper on CPU; loaded on first verify, not at boot."""
451
- global _consent_asr
452
- if _consent_asr is None:
453
- from transformers import pipeline as hf_pipeline
454
-
455
- _consent_asr = hf_pipeline(
456
- "automatic-speech-recognition", model=CONSENT_ASR_ID, device="cpu"
457
- )
458
- return _consent_asr
459
-
460
-
461
- def _word_tokens(text: str) -> list:
462
- return re.sub(r"[^a-z0-9 ]", "", (text or "").lower()).split()
463
-
464
-
465
- def verify_consent(recording_path, sentence):
466
- """Transcribe the consent recording; True when it matches the sentence."""
467
- if not recording_path:
468
- raise gr.Error("Record yourself reading the sentence first (microphone only).")
469
- sentence = (sentence or "").strip()
470
- if not sentence:
471
- raise gr.Error("Generate a consent sentence first.")
472
- wav, _ = librosa.load(recording_path, sr=16000, mono=True)
473
- if len(wav) < 16000 // 2:
474
- raise gr.Error("The recording is too short — read the whole sentence aloud.")
475
- heard = _load_consent_asr()({"raw": wav, "sampling_rate": 16000})["text"].strip()
476
- ratio = difflib.SequenceMatcher(
477
- None, _word_tokens(heard), _word_tokens(sentence)
478
- ).ratio()
479
- return ratio >= CONSENT_THRESHOLD, heard, ratio
480
-
481
-
482
- def on_generate_sentence():
483
- return (
484
- generate_consent_sentence(),
485
- False,
486
- "Now read the sentence aloud into your microphone, then verify.",
487
- )
488
-
489
-
490
- def on_verify(recording, sentence):
491
- verified, heard, ratio = verify_consent(recording, sentence)
492
- if not verified:
493
- return {
494
- consent_ok: False,
495
- consent_status: (
496
- f"❌ We heard: ��{heard}” ({ratio:.0%} match — needs "
497
- f"{CONSENT_THRESHOLD:.0%}). Read the sentence exactly as shown "
498
- "and verify again."
499
- ),
500
- }
501
- return {
502
- reference_audio: recording,
503
- reference_text: sentence,
504
- consent_ok: True,
505
- consent_status: (
506
- f"✅ Consent verified ({ratio:.0%} match). The recording is now your "
507
- "reference audio, and the sentence its transcript."
508
- ),
509
- }
510
-
511
-
512
- # Fish Audio Drama 3 Space draft: cream page, white cards,
513
- # and a black pill button.
514
- FISH_CSS = """
515
- :root, .gradio-container {
516
- --fa-bg: #f6f5f3; --fa-ink: #141414; --fa-muted: #6b6b66;
517
- --fa-card: #ffffff; --fa-border: #e2e0dc; --fa-card-border: #e8e6e2;
518
- }
519
- .dark, .dark .gradio-container {
520
- --fa-bg: #161615; --fa-ink: #f3f2ef; --fa-muted: #a8a7a1;
521
- --fa-card: #201f1d; --fa-border: #35332f; --fa-card-border: #35332f;
522
- }
523
- :root, .gradio-container, .dark, .dark .gradio-container {
524
- --body-text-color: var(--fa-ink);
525
- --body-text-color-subdued: var(--fa-muted);
526
- --block-label-text-color: var(--fa-ink);
527
- --block-title-text-color: var(--fa-ink);
528
- --button-primary-background-fill: var(--fa-ink);
529
- --button-primary-text-color: var(--fa-bg);
530
- --background-fill-primary: var(--fa-bg);
531
- --background-fill-secondary: var(--fa-card);
532
- --color-accent: var(--fa-ink);
533
- }
534
- body, .gradio-container, .app, .main {
535
- background: var(--fa-bg) !important;
536
- color: var(--fa-ink) !important;
537
- }
538
- .gradio-container .prose strong, .gradio-container .prose b,
539
- .gradio-container .prose h1, .gradio-container .prose h2, .gradio-container .prose h3,
540
- .gradio-container .prose li {
541
- color: var(--fa-ink) !important;
542
- }
543
- .gradio-container {
544
- max-width: 980px !important;
545
- margin-left: auto !important;
546
- margin-right: auto !important;
547
- font-family: ui-sans-serif, -apple-system, "Segoe UI", Inter, Roboto, sans-serif !important;
548
- }
549
- #fa-hero { margin: 36px 0 8px; }
550
- .fa-pill {
551
- display: inline-flex; align-items: center;
552
- border: 1px solid var(--fa-border); border-radius: 999px;
553
- background: var(--fa-card); color: var(--fa-ink);
554
- font-size: 13px; font-weight: 500; padding: 5px 14px;
555
- }
556
- #fa-hero h1 {
557
- font-size: clamp(30px, 5vw, 46px); font-weight: 800;
558
- letter-spacing: -0.025em; line-height: 1.08;
559
- max-width: 640px; margin: 18px 0 10px; color: var(--fa-ink);
560
- }
561
- #fa-hero p, #fa-intro p {
562
- font-size: 15px; line-height: 1.55; color: var(--fa-muted);
563
- max-width: 640px;
564
- }
565
- #fa-links { display: flex; gap: 8px; flex-wrap: wrap; margin: 18px 0 22px; }
566
- #fa-links a {
567
- border: 1px solid var(--fa-border); border-radius: 999px;
568
- background: var(--fa-card); padding: 6px 14px;
569
- font-size: 13px; font-weight: 500;
570
- color: var(--fa-ink) !important; text-decoration: none !important;
571
- }
572
- #fa-links a:hover { border-color: var(--fa-ink); }
573
- #fa-form .block, #fa-form .form {
574
- border-radius: 16px !important; border-color: var(--fa-card-border) !important;
575
- background: var(--fa-card) !important;
576
- box-shadow: 0 1px 2px rgba(20,20,20,0.04) !important;
577
- }
578
- #fa-form .prose, #fa-guide .prose, #fa-ack .prose {
579
- background: transparent !important; border: none !important;
580
- box-shadow: none !important;
581
- }
582
- #fa-form .prose p { color: var(--fa-muted); font-size: 13.5px; line-height: 1.5; }
583
- #fa-form label span, #fa-form .label-wrap span {
584
- font-weight: 600 !important; color: var(--fa-ink) !important;
585
- }
586
- #fa-form button.primary {
587
- border-radius: 999px !important;
588
- background: var(--fa-ink) !important; color: var(--fa-bg) !important;
589
- font-weight: 600 !important; border: none !important;
590
- }
591
- #fa-guide h3, #fa-ack h3 { letter-spacing: -0.02em; }
592
- footer { display: none !important; }
593
- """
594
-
595
- # Workflow additions only: hide the auto-generated form (the team's page below
596
- # replaces it) and dock the App/Workflow toggle bottom-center.
597
- WORKFLOW_CSS = """
598
- .workflow-app-body { display: none !important; }
599
- .view-toggle {
600
- left: 50% !important; right: auto !important;
601
- transform: translateX(-50%) !important;
602
- bottom: 18px !important;
603
- }
604
- """
605
-
606
- demo = gr.Workflow(
607
- graph="workflow.json",
608
- bind={"drama3_clone": drama3_clone},
609
- )
610
-
611
- # Mount the team's page inside the workflow's app view, in place of the
612
- # auto-generated form (hidden above). The canvas only covers #workflow-app's
613
- # overlay, so the toggle still switches to the workflow graph. Blocks appended
614
- # here are not tracked by WorkflowEndpointManager, so canvas saves keep them.
615
- from gradio.workflow_api import _active_blocks
616
-
617
- with _active_blocks(demo, demo._api_endpoints.app_root):
618
- gr.HTML(
619
- """
620
- <div id="fa-hero">
621
- <span class="fa-pill">Try · Drama 3 →</span>
622
- <h1>The most expressive, emotionally controllable real-time voice model</h1>
623
- <p>Five seconds is easy. Five minutes is the test. Drama 3 clones a voice
624
- from a few seconds of reference audio and holds its character from the
625
- first word to the last.</p>
626
- </div>
627
- """
628
- )
629
- gr.Markdown(
630
- "Clone one voice, then speak a new line in that voice. "
631
- "The reference clip and its transcript only describe the voice. "
632
- "**Text to generate** is the line that gets spoken. "
633
- "Write ordinary text. You do not need `S0:` or a speaker token. "
634
- "This page is a single-speaker preview. Multi-speaker scenes, streaming, "
635
- f"and the HTTP API are in the [SGLang Omni cookbook]({COOKBOOK_URL}).",
636
- elem_id="fa-intro",
637
- )
638
- gr.HTML(
639
- f"""
640
- <nav id="fa-links">
641
- <a href="{COOKBOOK_URL}" target="_blank" rel="noreferrer">SGLang Omni cookbook</a>
642
- <a href="{SGLANG_OMNI_URL}" target="_blank" rel="noreferrer">⭐ Star SGLang Omni</a>
643
- <a href="{TECHNICAL_REPORT_URL}" target="_blank" rel="noreferrer">Technical report</a>
644
- <a href="{FISH_AUDIO_URL}" target="_blank" rel="noreferrer">Fish Audio</a>
645
- <a href="{FISH_SPEECH_URL}" target="_blank" rel="noreferrer">Fish Speech</a>
646
- <a href="{MODEL_URL}" target="_blank" rel="noreferrer">Model card</a>
647
- </nav>
648
- """
649
- )
650
- consent_ok = gr.State(False)
651
- with gr.Row():
652
- with gr.Column(scale=3, elem_id="fa-form"):
653
- with gr.Accordion("🎤 Voice consent gate · required before cloning", open=True):
654
- gr.Markdown(
655
- "A voice is cloned only when its speaker explicitly agrees "
656
- "([voice consent gate](https://huggingface.co/blog/voice-consent-gate)): "
657
- "generate a unique sentence, read it aloud into your microphone, "
658
- "and we verify it was said. The same recording then serves as the "
659
- "reference audio — spoken consent and cloning input in one."
660
- )
661
- consent_sentence_btn = gr.Button("1 · Generate a consent sentence", size="sm")
662
- consent_sentence = gr.Textbox(
663
- label="Read this sentence aloud",
664
- interactive=False,
665
- lines=2,
666
- )
667
- consent_recording = gr.Audio(
668
- label="2 · Record yourself (microphone only)",
669
- sources=["microphone"],
670
- type="filepath",
671
- )
672
- consent_verify_btn = gr.Button("3 · Verify consent", size="sm", variant="secondary")
673
- consent_status = gr.Markdown("")
674
- reference_audio = gr.Audio(
675
- label="Reference audio",
676
- sources=["upload", "microphone"],
677
- type="filepath",
678
- )
679
- gr.Markdown(
680
- "The voice to copy. Upload a file or record with the microphone. "
681
- "Use one speaker, between **0.5 and 30 seconds**. "
682
- "A longer clip is rejected. It is not trimmed, so the transcript "
683
- "still matches the whole recording."
684
- )
685
- reference_text = gr.Textbox(
686
- label="Reference transcript",
687
- placeholder="Hello, nice to meet you. My name is Chenyang.",
688
- info="Exact words spoken in the reference audio. This is not the line that will be generated.",
689
- lines=2,
690
- )
691
- text_to_generate = gr.Textbox(
692
- label="Text to generate",
693
- placeholder="Have a nice day and enjoy Southern California sunshine.",
694
- info="The new line this voice should say. Plain text is enough.",
695
- lines=3,
696
- )
697
- generate_button = gr.Button("Generate", variant="primary")
698
- generated_audio = gr.Audio(label="Generated audio", interactive=False)
699
- gr.Markdown(
700
- "One playable clip of **Text to generate** in the reference voice. "
701
- "It should not repeat the reference recording."
702
- )
703
- with gr.Column(scale=2, elem_id="fa-guide"):
704
- gr.Markdown(
705
- "### What each field means\n"
706
- "**Reference audio** is the voice sample. "
707
- "It is a condition, not a previous take.\n\n"
708
- "**Reference transcript** is what that sample actually says. "
709
- "The model uses it to line the voice up with the words. "
710
- "Those words are not synthesized again.\n\n"
711
- "**Text to generate** is the only text spoken in the output. "
712
- "It is assigned to this one voice automatically.\n\n"
713
- "**Generate** runs one synthesis. "
714
- "There is no second revision pass.\n\n"
715
- "**Generated audio** is the result. Play it here or download it.\n\n"
716
- "### Example\n"
717
- "Reference transcript: "
718
- "`Hello, nice to meet you. My name is Chenyang.`\n\n"
719
- "Text to generate: "
720
- "`Have a nice day and enjoy Southern California sunshine.`\n\n"
721
- "The clip should say the second sentence, not the introduction.\n\n"
722
- "### Run the full model on SGLang Omni\n"
723
- f"- [Fish Audio Drama 3 cookbook]({COOKBOOK_URL}): "
724
- "install, voice cloning, and the HTTP API\n"
725
- f"- [SGLang Omni]({SGLANG_OMNI_URL}): "
726
- "the GitHub repo — please give it a star\n"
727
- f"- [Technical report]({TECHNICAL_REPORT_URL}): "
728
- "architecture of the 35B MoE model\n"
729
- f"- [Fish Audio Playground]({FISH_AUDIO_URL}): "
730
- "try Fish Audio voices in the browser\n"
731
- f"- [Fish Speech]({FISH_SPEECH_URL}): "
732
- "source repository for the model\n"
733
- f"- [drama-3 model card]({MODEL_URL}): "
734
- "weights, license, and checkpoint files"
735
- )
736
- gr.Markdown(
737
- "### Acknowledgments\n"
738
- "It took us over half a year to bring SGLang Omni back to life. "
739
- "Thanks to all the people who helped us along the way.\n\n"
740
- f"Thanks so much for [AK]({AK_URL}) help to set up the space.\n\n"
741
- "**SGLang Omni team:** "
742
- f"[Chenyang Zhao]({CHENYANG_URL}), Jingwen Gu, Shenggui Li, Richard Wang, "
743
- "Yichi Zhang, Xuesong Ye, Jiaxin Deng, Ratish P, Jiaxuan Luo, Xinyu Lu, "
744
- "Wenyao Gao, Ao Sun, Jintao Quaige Li, Yuhao Chen, Yuhan Zhou, "
745
- "Xuxiang Tian, Ruoyi Zhang, Junnan Li, Yixiao Zeng, Chenchen Hong, "
746
- "Huapeng Zhou, Yang Gao, Chaoye Fei, Yiyang Zh, Kang, Yitian Gong, "
747
- "Yuan Luo, Hao Jin, Yifei Gao, Gaokai Zhang, Lifan Shen, Hui Shen, "
748
- "Kejing Xia, Jinxin Li, Dongming Shen, Jun Liu, Yue Yin, Zhihao Guo, "
749
- "Jisen Li, Ruoyu He, Xuanyi Li, Chenye Zhu, Liang Zhen, Xiaodong Ye, "
750
- "Yizhi Zhen, Kaizuo Li, Chonghan Liu, Wei Guan, Zhuofeng Li\n\n"
751
- "**FishAudio Team:** Shijia Liao, Shidong Li, Xuehao Yang, Jiahua Liu",
752
- elem_id="fa-ack",
753
- )
754
- consent_sentence_btn.click(
755
- fn=on_generate_sentence,
756
- outputs=[consent_sentence, consent_ok, consent_status],
757
- api_name=False,
758
- )
759
- consent_verify_btn.click(
760
- fn=on_verify,
761
- inputs=[consent_recording, consent_sentence],
762
- outputs=[reference_audio, reference_text, consent_ok, consent_status],
763
- api_name=False,
764
- )
765
- generate_button.click(
766
- fn=synthesize,
767
- inputs=[reference_audio, reference_text, text_to_generate, consent_ok],
768
- outputs=generated_audio,
769
- api_name="synthesize",
770
- concurrency_limit=1,
771
- )
772
-
773
- demo.queue(max_size=16, default_concurrency_limit=1)
774
-
775
- if __name__ == "__main__":
776
- demo.launch(css=FISH_CSS + WORKFLOW_CSS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
drama3_space_ext.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Drama 3 prompt layouts that SGLang Omni's /v1/audio/speech cannot express.
2
+
3
+ The stock Drama 3 preprocessor builds one layout: optional voice references in the
4
+ system turn, then a single user turn (Fish Audio Drama 3 Technical Report, Figure 11).
5
+ Drama 3 was also trained on two layouts this Space uses:
6
+
7
+ - casting a voice from a text description, with no reference audio (Figure 13), and
8
+ - multi-turn directing: a note in a later user turn revises the take the model just
9
+ produced, which sits in the prompt as an earlier assistant turn (Figure 12).
10
+
11
+ Drama3SpacePreprocessor reads a JSON envelope from the request text and builds those
12
+ layouts. Requests without the envelope take the stock path unchanged. The engine
13
+ listens on localhost only, so the Space's web server is the only client that can send
14
+ an envelope.
15
+
16
+ SGLang Omni declares each stage's factory in the model's config class and refuses to
17
+ override it from the command line. The Dockerfile instead appends one line to
18
+ sglang_omni/models/fishaudio_drama3/stages.py that rebinds the name its preprocessing
19
+ factory builds, Drama3SpeechPreprocessor, to this class.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import dataclasses
25
+ import json
26
+ import logging
27
+ from collections.abc import Mapping, Sequence
28
+
29
+ import torch
30
+
31
+ from sglang_omni.models.fishaudio_drama3.prompt import (
32
+ GENERATION_START,
33
+ MESSAGE_END,
34
+ REFERENCE_INSTRUCTION,
35
+ SEMANTIC_BEGIN_TOKEN,
36
+ SPEAKER_TAG_PATTERN,
37
+ TTS_INSTRUCTION,
38
+ UNICODE_WHITE_SPACE,
39
+ validate_caller_text,
40
+ with_speaker_tag,
41
+ )
42
+ from sglang_omni.models.fishaudio_drama3.request_builders import REQUEST_ERROR_PREFIX
43
+ from sglang_omni.models.fishaudio_drama3.speech_request import (
44
+ Drama3SpeechPreprocessor,
45
+ speech_inputs,
46
+ )
47
+ from sglang_omni.proto.request import OmniRequest, StagePayload
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ ENVELOPE_MARKER = "@@drama3-space@@"
52
+ # The scene-dubbing system instruction of Figure 13. The engine samples only audio
53
+ # and end-of-speech tokens, so a request carries one line and never needs the
54
+ # separator; the instruction keeps the layout the model was trained on.
55
+ SCENE_INSTRUCTION = (
56
+ "Voice every line of the script in order, with <|audio_separator|> "
57
+ "between consecutive lines."
58
+ )
59
+
60
+
61
+ @dataclasses.dataclass(frozen=True)
62
+ class Character:
63
+ name: str
64
+ description: str
65
+ scene: str | None
66
+
67
+
68
+ @dataclasses.dataclass(frozen=True)
69
+ class Envelope:
70
+ """One request: the line to speak now and how its prompt is laid out.
71
+
72
+ The request's references hold voice_references voice clips first, then one
73
+ clip per history turn, in order.
74
+ """
75
+
76
+ text: str
77
+ voice_references: int
78
+ history: tuple[str, ...]
79
+ character: Character | None
80
+
81
+
82
+ def request_error(message: str) -> ValueError:
83
+ return ValueError(f"{REQUEST_ERROR_PREFIX} {message}")
84
+
85
+
86
+ def single_line(value: object, field_name: str, *, optional: bool = False) -> str | None:
87
+ if value is None and optional:
88
+ return None
89
+ if not isinstance(value, str) or not value.strip(UNICODE_WHITE_SPACE):
90
+ raise request_error(f"{field_name} must be a non-empty string")
91
+ if "\n" in value or "\r" in value:
92
+ raise request_error(f"{field_name} must be a single line")
93
+ validate_caller_text(value, field_name)
94
+ return value.strip(UNICODE_WHITE_SPACE)
95
+
96
+
97
+ def parse_envelope(body: str, reference_count: int) -> Envelope:
98
+ try:
99
+ spec = json.loads(body)
100
+ except json.JSONDecodeError as error:
101
+ raise request_error(f"malformed envelope: {error}") from error
102
+ if not isinstance(spec, Mapping):
103
+ raise request_error("the envelope must be a JSON object")
104
+
105
+ text = spec.get("text")
106
+ if not isinstance(text, str):
107
+ raise request_error("envelope text must be a string")
108
+ validate_caller_text(text, "text")
109
+
110
+ history = spec.get("history") or []
111
+ if not isinstance(history, list) or not all(isinstance(t, str) for t in history):
112
+ raise request_error("envelope history must be a list of strings")
113
+ for index, turn_text in enumerate(history):
114
+ validate_caller_text(turn_text, f"history.{index}")
115
+
116
+ voice_references = spec.get("voice_references", 0)
117
+ if (
118
+ isinstance(voice_references, bool)
119
+ or not isinstance(voice_references, int)
120
+ or voice_references < 0
121
+ or voice_references + len(history) != reference_count
122
+ ):
123
+ raise request_error(
124
+ f"voice_references {voice_references!r} and {len(history)} history "
125
+ f"turns do not account for {reference_count} references"
126
+ )
127
+
128
+ character = None
129
+ raw_character = spec.get("character")
130
+ if raw_character is not None:
131
+ if not isinstance(raw_character, Mapping):
132
+ raise request_error("envelope character must be an object")
133
+ if voice_references:
134
+ raise request_error("a described character cannot also have voice references")
135
+ name = single_line(raw_character.get("name"), "character.name")
136
+ if ":" in name:
137
+ raise request_error("character.name must not contain ':'")
138
+ character = Character(
139
+ name=name,
140
+ description=single_line(
141
+ raw_character.get("description"), "character.description"
142
+ ),
143
+ scene=single_line(raw_character.get("scene"), "character.scene", optional=True),
144
+ )
145
+
146
+ return Envelope(
147
+ text=text,
148
+ voice_references=voice_references,
149
+ history=tuple(history),
150
+ character=character,
151
+ )
152
+
153
+
154
+ def reference_text_block(transcript: str) -> str:
155
+ """The stock reference transcript: the speaker tag follows "Text: " directly."""
156
+ tagged = with_speaker_tag(transcript, 0)
157
+ body = tagged.lstrip(UNICODE_WHITE_SPACE)
158
+ tag = SPEAKER_TAG_PATTERN.match(body)
159
+ assert tag is not None
160
+ leading = tagged[: len(tagged) - len(body)]
161
+ return tag.group(0) + leading + body[tag.end() :]
162
+
163
+
164
+ class Drama3SpacePreprocessor(Drama3SpeechPreprocessor):
165
+ """The stock preprocessor, plus described characters and directing history."""
166
+
167
+ def __init__(self, **kwargs: object) -> None:
168
+ super().__init__(**kwargs)
169
+ # server.py looks for this line to confirm the stages.py hook took effect.
170
+ logger.warning("Drama 3 Space preprocessor active")
171
+
172
+ def __call__(self, payload: StagePayload) -> StagePayload:
173
+ text, descriptors = speech_inputs(payload.request.inputs)
174
+ if not text.startswith(ENVELOPE_MARKER):
175
+ return super().__call__(payload)
176
+ envelope = parse_envelope(text[len(ENVELOPE_MARKER) :], len(descriptors))
177
+
178
+ # The stock path checks the sampling fields and the line itself; the rows it
179
+ # builds for that reference-free probe are replaced below.
180
+ probe = StagePayload(
181
+ request_id=payload.request_id,
182
+ request=OmniRequest(
183
+ inputs=self.turn_text(envelope.text, envelope.character),
184
+ params=payload.request.params,
185
+ metadata=payload.request.metadata,
186
+ ),
187
+ data=None,
188
+ )
189
+ checked = super().__call__(probe)
190
+
191
+ codes = [
192
+ self.reference_audio_codes(descriptor, index)
193
+ for index, descriptor in enumerate(descriptors)
194
+ ]
195
+ voice_codes = codes[: envelope.voice_references]
196
+ history_codes = codes[envelope.voice_references :]
197
+ transcripts = []
198
+ for index, descriptor in enumerate(descriptors[: envelope.voice_references]):
199
+ transcript = descriptor.get("text")
200
+ if not isinstance(transcript, str):
201
+ raise request_error(f"references.{index}.text must be a string")
202
+ validate_caller_text(transcript, f"references.{index}")
203
+ transcripts.append(transcript)
204
+
205
+ segments: list[str | torch.Tensor] = ["<|im_start|>system\n"]
206
+ if envelope.character is not None:
207
+ character = envelope.character
208
+ lines = [f"Scene: {character.scene}"] if character.scene else []
209
+ lines += [
210
+ "Characters:",
211
+ f"- {character.name}: {character.description}",
212
+ SCENE_INSTRUCTION,
213
+ ]
214
+ segments.append("\n".join(lines))
215
+ else:
216
+ segments.append(REFERENCE_INSTRUCTION if voice_codes else TTS_INSTRUCTION)
217
+ for index, (transcript, reference) in enumerate(
218
+ zip(transcripts, voice_codes, strict=True), 1
219
+ ):
220
+ segments += [
221
+ f"\n\n[Reference {index}]\nText: "
222
+ f"{reference_text_block(transcript)}\nSpeech: ",
223
+ reference,
224
+ ]
225
+ segments.append(MESSAGE_END)
226
+ for turn_text, take in zip(envelope.history, history_codes, strict=True):
227
+ segments += [
228
+ "<|im_start|>user\n",
229
+ self.turn_text(turn_text, envelope.character),
230
+ MESSAGE_END,
231
+ GENERATION_START,
232
+ take,
233
+ MESSAGE_END,
234
+ ]
235
+ segments += [
236
+ "<|im_start|>user\n",
237
+ self.turn_text(envelope.text, envelope.character),
238
+ MESSAGE_END,
239
+ GENERATION_START,
240
+ ]
241
+
242
+ rows = self.segment_rows(segments)
243
+ if rows.shape[0] > self.max_prompt_rows:
244
+ raise request_error(
245
+ f"a prompt of {rows.shape[0]} rows exceeds the maximum allowed "
246
+ f"length of {self.max_prompt_rows} rows"
247
+ )
248
+ return StagePayload(
249
+ request_id=payload.request_id,
250
+ request=payload.request,
251
+ data=dataclasses.replace(checked.data, prompt_rows=rows),
252
+ )
253
+
254
+ @staticmethod
255
+ def turn_text(text: str, character: Character | None) -> str:
256
+ if character is not None:
257
+ return f"{character.name}: {text}"
258
+ return with_speaker_tag(text, 0)
259
+
260
+ def segment_rows(self, segments: Sequence[str | torch.Tensor]) -> torch.Tensor:
261
+ """Text rows are [token, 0, ...]; audio rows [c0 + semantic_begin, c0..cN-1].
262
+
263
+ Every literal is tokenized on its own, as in the stock builder: tokenizing
264
+ the joined string changes ids at the seams.
265
+ """
266
+ semantic_begin_id = self.tokenizer.token_to_id(SEMANTIC_BEGIN_TOKEN)
267
+ rows: list[torch.Tensor] = []
268
+ for segment in segments:
269
+ if isinstance(segment, str):
270
+ token_ids = torch.tensor(
271
+ self.tokenizer.encode(segment).ids, dtype=torch.long
272
+ )
273
+ text_rows = torch.zeros(
274
+ (token_ids.numel(), self.num_codebooks + 1), dtype=torch.long
275
+ )
276
+ text_rows[:, 0] = token_ids
277
+ rows.append(text_rows)
278
+ else:
279
+ audio_codes = segment.to(device="cpu", dtype=torch.long)
280
+ if (
281
+ audio_codes.ndim != 2
282
+ or audio_codes.shape[0] != self.num_codebooks
283
+ or audio_codes.shape[1] == 0
284
+ ):
285
+ raise request_error(
286
+ f"audio codes must be [{self.num_codebooks}, frames]; got "
287
+ f"{tuple(audio_codes.shape)}"
288
+ )
289
+ rows.append(
290
+ torch.cat([audio_codes[:1] + semantic_begin_id, audio_codes]).T
291
+ )
292
+ return torch.cat(rows)
endpoint/.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ status.json
3
+ logs.txt
4
+ verification.json
5
+ *.wav
6
+ comparisons/
endpoint/PERFORMANCE.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # H200 performance investigation — 14 October 2026
2
+
3
+ These initial measurements precede the later transport optimizations described
4
+ in [README.md](README.md#measure-ui-and-network-overhead), including the current
5
+ 2.5-second opening threshold, streaming compression, and HTTP/2 connection reuse.
6
+
7
+ The H200 was correctly detected and already using CUDA graphs, Triton sampling,
8
+ and fused tower layers. One-step asynchronous decoding was disabled. It is now
9
+ enabled for batch sizes starting at one, on the same endpoint, image, model
10
+ revision, GCP region, replica count, and idle scaling policy.
11
+
12
+ ## Warm endpoint measurements
13
+
14
+ Each run includes four measured Surprise and four measured Clone requests,
15
+ plus one excluded warm-up per mode. The same 31-word script, exact reference
16
+ PCM, and temperature 1 / top-p 0.9 / top-k 4097 are used throughout. Audio lengths
17
+ vary because this model has no request seed. Times are measured from the client
18
+ in Europe and include authentication, network, and reference uploads.
19
+
20
+ | Measurement (median) | Before | Async decode | Async + lossless FLAC reference |
21
+ | --- | ---: | ---: | ---: |
22
+ | Streaming audio seconds per elapsed second, after first chunk | 8.24× | 10.45× | 10.13× |
23
+ | First audio, Surprise | 775 ms | 762 ms | 760 ms |
24
+ | First audio, Clone | 1,573 ms | 1,450 ms | 1,272 ms |
25
+ | Normal stops | 8/8 | 8/8 | 8/8 |
26
+
27
+ Async decoding improved measured streaming speed by about 27%. Separate steady
28
+ single-request decode log samples increased from a median 168 to 197 frames/s
29
+ (about 17%). These measurements describe different layers: the streaming ratio
30
+ also includes vocoding, chunk timing, and transfer; it is not a pure GPU benchmark.
31
+
32
+ The cloned reference shrank from 862,106 to 417,548 bytes with FLAC, with identical
33
+ decoded PCM16 samples. The Space now caches that compressed representation for
34
+ voice uploads and uses FLAC for directing-history audio too. Browser downloads
35
+ remain WAV. Native API callers can also send `media_type="audio/flac"`.
36
+
37
+ ## Why the Space can feel slower
38
+
39
+ The current Space runs on CPU and proxies the same H200 endpoint. It is no longer
40
+ an independent GPU control. No controlled A6000-versus-H200 conclusion can be
41
+ drawn from these tests.
42
+
43
+ Before the packet change, a valid three-second opening was sent as one large
44
+ base64 SSE event. The client cannot decode that event until the whole line has
45
+ arrived. In the async test, the Space measured about 0.8–1.1 seconds to prepare
46
+ first audio, but the client's complete audio event arrived several seconds later.
47
+ This is evidence of delivery overhead, not slow GPU decoding.
48
+
49
+ The Space now splits validated audio into packets of at most half a second.
50
+ It keeps the same three-second opening validation, silence trimming, early retry,
51
+ quota accounting, and cancellation behavior. First-audio timings from the server
52
+ describe audio preparation; browser playback also includes delivery and rendering.
53
+
54
+ GCP `us-south1` adds a transatlantic trip for European clients. The endpoint also
55
+ still scales to zero after 60 idle minutes; a cold start has to reload weights and
56
+ capture GPU graphs. The observed configuration restart took about 3 minutes
57
+ 20 seconds to become healthy, and the first speech request then took about six
58
+ seconds. Neither time is included in the warm medians above.
59
+
60
+ ## Validation and limits
61
+
62
+ 50 local checks cover streaming packets, exact reference PCM preservation, Gradio,
63
+ Workflow, quotas, cancellation, failed-opening retries, and safe serving updates.
64
+ Live testing also ran four simultaneous requests, cancelled one after first audio,
65
+ and verified that the other three and a subsequent request stopped normally.
66
+
67
+ Four endpoint samples per before/async run were transcribed through the same
68
+ Whisper service. Before: four exact scripts. After: three exact scripts and one
69
+ with the final word missing from transcription and a long pause. All requests
70
+ stopping normally does not prove that every script is complete. These small samples
71
+ are a speed investigation, not evidence that silence or completion issues are fixed.
72
+ The requested sampling distribution is unchanged by the serving optimization.
73
+
74
+ Reproduce using `benchmark_speed.py`; raw audio, arrival times, and summaries live
75
+ under the Git-ignored `endpoint/comparisons/speed-*` directories. Published upstream
76
+ [H200 benchmarks](https://sgl-project.github.io/sglang-omni/cookbook/fishaudio_drama3.html#benchmarks)
77
+ use top-k 30 and pre-encoded voice references, so their sub-100 ms first-audio
78
+ results should not be compared directly with this authenticated remote API test.
endpoint/README.md ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Drama 3 on a GCP H200
2
+
3
+ The inference server is deployed as **HuggingFaceM4/drama-3-h200** on Hugging Face
4
+ Inference Endpoints, backed by **one Nvidia H200 (141 GB)** in GCP **us-south1**.
5
+ At deployment, the hardware catalog quotes **$5/hour while running**. There is at
6
+ most one replica, and it scales to zero after **60 minutes without requests**.
7
+ The endpoint requires a Hugging Face token with access to the organization.
8
+
9
+ ## Server and reproducibility
10
+
11
+ `deployment.json` pins both the `fishaudio/drama-3` model revision and the existing
12
+ Drama-3 Space image by digest. The image includes SGLang Omni at
13
+ `22d4ace88f9547777b819a69d7691204bb9ce847` and the voice-description / directing-history
14
+ prompt extension. The endpoint runs SGLang Omni directly on port 8000 with model
15
+ weights mounted at `/repository`; it exposes `/health` and `/v1/audio/speech`.
16
+ H200 enables the engine's Hopper-compatible sampling and fused tower kernels.
17
+ Asynchronous decoding overlaps the next GPU step with CPU result processing,
18
+ including for one active request (`async_decode_min_batch_size=1`).
19
+ The server caps each generation at 1200 audio frames (about 56 seconds).
20
+
21
+ The Space now runs on CPU and sends generation to this endpoint using `DRAMA_ENDPOINT_URL` and a server-only `DRAMA_ENDPOINT_TOKEN` secret. Its CPU `Dockerfile` is separate from the retained `Dockerfile.engine` GPU recipe; editing the UI does not rebuild the endpoint.
22
+
23
+ The endpoint serves the native speech API. The Space's silence trimming, early
24
+ retry, take storage, presets, quotas, and Gradio controls belong to its web server.
25
+
26
+ To recreate the endpoint, using an authenticated Hugging Face environment:
27
+
28
+ ```sh
29
+ python endpoint/deploy.py
30
+ python endpoint/watch.py
31
+ python endpoint/verify.py
32
+ ```
33
+
34
+ To apply updated serving arguments to this existing, matching deployment:
35
+
36
+ ```sh
37
+ python endpoint/deploy.py --update-serving
38
+ python endpoint/watch.py
39
+ ```
40
+
41
+ This restarts the server. It checks the pinned image, model revision, command,
42
+ provider, and hardware first; it changes only container arguments.
43
+
44
+ The deploy script checks quota and reuses an existing deployment with the same
45
+ model and hardware instead of creating another server. Runtime status, logs,
46
+ verification results, and sample WAVs are ignored by Git. `verify.py` checks a
47
+ plain script, an expressive script, and voice cloning through the streaming API.
48
+
49
+ ## Call the server
50
+
51
+ The endpoint URL is discovered from the API rather than hard-coded:
52
+
53
+ ```python
54
+ import os
55
+ import httpx
56
+ from huggingface_hub import HfApi
57
+
58
+ endpoint = HfApi().get_inference_endpoint(
59
+ "drama-3-h200", namespace="HuggingFaceM4"
60
+ )
61
+ response = httpx.post(
62
+ endpoint.url + "/v1/audio/speech",
63
+ headers={"Authorization": "Bearer " + os.environ["HF_TOKEN"]},
64
+ json={
65
+ "input": "Good evening. Welcome to Drama Three.",
66
+ "response_format": "wav",
67
+ "max_new_tokens": 480,
68
+ "temperature": 1.0,
69
+ "top_p": 0.9,
70
+ "top_k": 4097,
71
+ },
72
+ timeout=300,
73
+ )
74
+ response.raise_for_status()
75
+ with open("speech.wav", "wb") as audio:
76
+ audio.write(response.content)
77
+ ```
78
+
79
+ For streaming, set `stream=true`, `response_format="pcm"`, and
80
+ `stream_format="sse"`. Audio arrives in `speech.audio.delta` events as base64 PCM16;
81
+ `speech.audio.done` carries the finish reason. Close the response after that event.
82
+ For cloning, send `references` containing base64 audio, its media type, and transcript.
83
+ Lossless FLAC (`audio/flac`) reduces reference uploads while preserving PCM samples;
84
+ the Space uses this format for clones and directing history.
85
+ A scaled-to-zero endpoint needs time to reload the model on the next request.
86
+ Native top-k must be a positive integer: 4097 includes all 4096 codes plus EOS.
87
+ Omitting it uses upstream's capped default of 30; zero is not accepted by this API.
88
+
89
+ ## Measure streaming speed
90
+
91
+ ```sh
92
+ python endpoint/benchmark_speed.py --out endpoint/comparisons/speed --repeats 4 --include-space
93
+ ```
94
+
95
+ The benchmark keeps temperature 1, top-p 0.9, and uncapped top-k. It saves audio,
96
+ chunk arrival times, first-audio latency, and streaming speed for repeated Surprise
97
+ and Clone takes. One warm-up request per voice mode is excluded from medians.
98
+ Client timings include network and authentication; streaming speed excludes the
99
+ first chunk but still includes vocoding and transport. Audio is stochastic and
100
+ the model has no request seed. The current CPU Space uses this same endpoint,
101
+ so this measures UI/proxy overhead rather than comparing two GPUs.
102
+ Use `--reference-format flac` to measure lossless compressed reference uploads.
103
+ See [PERFORMANCE.md](PERFORMANCE.md) for the measured before/after results and limits.
104
+
105
+ ## Measure UI and network overhead
106
+
107
+ ```sh
108
+ python endpoint/benchmark_transport.py --out endpoint/comparisons/transport --repeats 3
109
+ ```
110
+
111
+ This uses HTTP/2 and measures the native endpoint, Space `/api/generate`, and the
112
+ actual Gradio stage queue with the same script and sampling settings. It records
113
+ first audio, completion, HTTP version, compressed wire bytes, and server timing
114
+ breakdowns. One warm-up per route is excluded. Use `--compression identity` to
115
+ disable response compression for a control run.
116
+
117
+ The CPU Space maintains an HTTP/2 client with a 120-second idle connection lifetime.
118
+ Successful endpoint health or speech responses avoid another health request for
119
+ 30 seconds; there are no background pings, so idle scale-to-zero still works.
120
+ The Space compresses SSE messages with gzip and flushes every packet immediately.
121
+ This covers both the proxy and Gradio's event stream, without compressing internal
122
+ Python iterators or changing the PCM audio, voice references, or sampling settings.
123
+ Clients that do not accept gzip continue receiving plain SSE. Opening validation
124
+ uses 2.5 seconds of retained audio. The early engine windows total about 2.97
125
+ seconds; the previous 3-second threshold could miss that boundary and wait for
126
+ another 64-frame window. Failed openings below the threshold are still withheld
127
+ and retried; their trailing silence cannot push them into playback.
128
+
129
+ ## Compare with the Space
130
+
131
+ ```sh
132
+ python endpoint/compare.py --out endpoint/comparisons/run --repeats 4
133
+ python endpoint/analyze_comparison.py --out endpoint/comparisons/run
134
+ ```
135
+
136
+ These scripts record 24 requests per deployment across six English scripts, with
137
+ matching model revision, prompts, references, sampling controls, and text-dependent
138
+ generation limits. The Space includes its production trimming and retry behavior;
139
+ the endpoint returns native audio. This compares the deployed services, rather
140
+ than isolating GPU hardware. Audio, request outcomes, transcriptions from the
141
+ same Whisper service, and a paired audio report are saved under the output folder.
142
+ The report measures script completion and quiet gaps, with explicit ASR limits;
143
+ it does not rate subjective timbre or expressiveness.
endpoint/analyze_comparison.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transcribe complete generated clips through the same Whisper service; score both."""
2
+ import argparse
3
+ from collections import Counter
4
+ import html
5
+ import io
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ import statistics
10
+
11
+ import httpx
12
+ import numpy as np
13
+ import soundfile as sf
14
+ from huggingface_hub import get_token
15
+
16
+ SPACE = 'https://andito-drama-3.hf.space'
17
+
18
+
19
+ def words(text):
20
+ return re.findall(r"[a-z0-9]+(?:'[a-z0-9]+)?", text.lower().replace('’', "'"))
21
+
22
+
23
+ def alignment(reference, transcript):
24
+ ref, hyp = words(reference), words(transcript)
25
+ dp = [[0] * (len(hyp)+1) for _ in range(len(ref)+1)]
26
+ for i in range(len(ref)+1):
27
+ dp[i][0] = i
28
+ for j in range(len(hyp)+1):
29
+ dp[0][j] = j
30
+ for i in range(1, len(ref)+1):
31
+ for j in range(1, len(hyp)+1):
32
+ dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+(ref[i-1] != hyp[j-1]))
33
+ i, j, counts, missing, extra = len(ref), len(hyp), Counter(), [], []
34
+ while i or j:
35
+ if i and j and dp[i][j] == dp[i-1][j-1] + (ref[i-1] != hyp[j-1]):
36
+ if ref[i-1] != hyp[j-1]:
37
+ counts['substitutions'] += 1
38
+ missing.append(ref[i-1]); extra.append(hyp[j-1])
39
+ i -= 1; j -= 1
40
+ elif i and dp[i][j] == dp[i-1][j] + 1:
41
+ counts['deletions'] += 1; missing.append(ref[i-1]); i -= 1
42
+ else:
43
+ counts['insertions'] += 1; extra.append(hyp[j-1]); j -= 1
44
+ return {'word_error_rate': dp[-1][-1] / max(1, len(ref)),
45
+ 'word_coverage': (len(ref)-counts['deletions']-counts['substitutions']) / max(1, len(ref)),
46
+ 'extra_word_fraction': counts['insertions'] / max(1, len(ref)),
47
+ 'missing_or_changed_words': list(reversed(missing)), 'extra_or_changed_words': list(reversed(extra)),
48
+ **{k: counts[k] for k in ['substitutions','deletions','insertions']}}
49
+
50
+
51
+ def transcribe(client, wav_path):
52
+ samples, rate = sf.read(wav_path, dtype='float32')
53
+ # Split only when necessary: the Space's reference API caps uploads at 20 s.
54
+ # No overlap, so transcripts cannot acquire artificial repetitions.
55
+ parts = []
56
+ details = []
57
+ for index, start in enumerate(range(0, len(samples), 18*rate)):
58
+ chunk = samples[start:start+18*rate]
59
+ if len(chunk) == 0 or np.sqrt(np.mean(chunk**2)) < .00003:
60
+ details.append({'part': index, 'note': 'Effectively silent segment'})
61
+ continue
62
+ buffer = io.BytesIO()
63
+ sf.write(buffer, chunk, rate, format='WAV', subtype='PCM_16')
64
+ response = client.post(SPACE + '/api/voices', files={'audio': (f'comparison-{index}.wav', buffer.getvalue(), 'audio/wav')})
65
+ if response.status_code == 400 and 'too short' in response.text:
66
+ details.append({'part': index, 'note': 'Speech segment too short for reference transcription'})
67
+ continue
68
+ response.raise_for_status()
69
+ payload = response.json()
70
+ if not payload.get('transcribed'):
71
+ raise RuntimeError('Whisper transcription was unavailable; cannot compare word accuracy')
72
+ parts.append(payload['transcript'])
73
+ details.append({'part': index, 'transcript': payload['transcript'], 'language': payload.get('language')})
74
+ return ' '.join(parts).strip(), details
75
+
76
+
77
+ def summarize(results):
78
+ summary = {}
79
+ for source in ['space','endpoint']:
80
+ rows = [r for r in results if r['source'] == source]
81
+ normal = [r for r in rows if r.get('finish_reason') == 'stop' and not r.get('error')]
82
+ complete = [r for r in rows if r.get('script_complete')]
83
+ clean = [r for r in rows if r.get('clean_take')]
84
+ retry_successes = [r for r in clean if r['retry_count']]
85
+ summary[source] = {'takes': len(rows), 'normal_termination': len(normal), 'script_complete': len(complete),
86
+ 'clean_takes': len(clean), 'long_internal_pauses': sum(r['longest_internal_quiet_s'] > 2 for r in rows),
87
+ 'long_trailing_silence': sum(r['trailing_quiet_s'] > 2 for r in rows),
88
+ 'early_errors': sum(bool(r.get('error')) for r in rows),
89
+ 'transcription_incomplete': sum(bool(r.get('transcription_incomplete')) for r in rows),
90
+ 'stalled_or_length_capped': sum(r.get('finish_reason') in {'stall','length','timeout','incomplete'} for r in rows),
91
+ 'retried_takes': sum(bool(r['retry_count']) for r in rows), 'successful_retries': len(retry_successes),
92
+ 'clean_without_retry': len(clean)-len(retry_successes),
93
+ 'max_internal_quiet_s': max(r['longest_internal_quiet_s'] for r in rows),
94
+ 'max_trailing_quiet_s': max(r['trailing_quiet_s'] for r in rows),
95
+ 'median_word_error_rate': statistics.median(r['word_error_rate'] for r in rows),
96
+ 'median_first_audio_client_ms': statistics.median(r['first_audio_client_ms'] for r in rows if r['first_audio_client_ms'] is not None)}
97
+ return summary
98
+
99
+
100
+ def write_report(out, results, summary):
101
+ escape = html.escape
102
+ cards = []
103
+ keys = list(dict.fromkeys((r['case'], r['repeat']) for r in results))
104
+ for case, repeat in keys:
105
+ pair = [r for r in results if r['case'] == case and r['repeat'] == repeat]
106
+ panels = []
107
+ for source in ['space','endpoint']:
108
+ r = next(x for x in pair if x['source'] == source)
109
+ player = f'<audio controls preload="none" src="{escape(r["audio_file"])}"></audio>' if r.get('audio_file') else '<p>No playable take</p>'
110
+ details = (f"Finished: {r.get('finish_reason', 'error')} · Script coverage: {r['word_coverage']:.0%} · "
111
+ f"Internal quiet: {r['longest_internal_quiet_s']:.2f} s · Trailing quiet: {r['trailing_quiet_s']:.2f} s · Retries: {r['retry_count']}")
112
+ panels.append(f'<section><h3>{escape(source.title())}</h3>{player}<p class="metrics">{escape(details)}</p><p>{escape(r.get("transcript", ""))}</p><p class="failure">{escape(r.get("error", ""))}</p></section>')
113
+ cards.append(f'<article><h2>{escape(case)} · take {repeat}</h2><p class="script">{escape(pair[0]["expected"])}</p><div class="pair">{"".join(panels)}</div></article>')
114
+ rows = []
115
+ for label, key in [('Completed script', 'script_complete'), ('Completed without long silence', 'clean_takes'), ('Long internal pauses (>2 s)', 'long_internal_pauses'), ('Long trailing silence (>2 s)', 'long_trailing_silence'), ('Stalled / reached generation limit', 'stalled_or_length_capped'), ('Error before playback', 'early_errors'), ('Automatic retries', 'retried_takes')]:
116
+ rows.append(f'<tr><th>{escape(label)}</th><td>{summary["space"][key]}</td><td>{summary["endpoint"][key]}</td></tr>')
117
+ page = f'''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Drama 3 — Space vs H200</title><style>body{{font:16px system-ui;max-width:1100px;margin:auto;padding:32px;background:#f6f4ee;color:#24211e}}h1{{font-size:32px}}article{{background:white;border:1px solid #d9d5cd;border-radius:16px;padding:22px;margin:22px 0}}.pair{{display:grid;grid-template-columns:1fr 1fr;gap:24px}}audio{{width:100%}}.metrics{{font-size:13px;color:#665f56}}.script{{font-weight:600}}.failure{{color:#a53124}}table{{border-collapse:collapse;background:white;width:100%}}th,td{{padding:10px;text-align:left;border-bottom:1px solid #ddd}}.method{{line-height:1.5}}@media(max-width:700px){{.pair{{grid-template-columns:1fr}}}}</style><h1>Drama 3: Space vs H200 endpoint</h1><p class="method">24 requests per server: six scripts, four repetitions. Matching model revision, prompts, reference audio, sampling settings, and generation limits. Space: RTX PRO 6000 with silence trimming and up to one retry. Endpoint: H200 with native, unprocessed audio. Random draws differ, and Surprise can choose different voices. This compares the deployed services; it does not isolate hardware alone.</p><p class="method">Same Whisper service transcribes both outputs. Clips longer than 18 seconds are transcribed in non-overlapping parts. Script completion means at least 90% of words matched and no substantial added-word sequence (insertions ≤25% of script length). Clean takes also require a normal stop and no internal or trailing quiet segment over 2 seconds. Quiet uses the Space's RMS threshold of 0.003; very soft speech may count as quiet. Segments with less than one second of speech after trimming cannot be transcribed by the reference API; they are marked unverified and do not count as a verified completed script. ASR can mishear words, especially whispers, laughter and split boundaries. These are reliability measures, not a subjective voice-quality score.</p><table><tr><th>Result (out of 24)</th><th>Space</th><th>H200 endpoint</th></tr>{''.join(rows)}</table>{''.join(cards)}</html>'''
118
+ (out / 'comparison.html').write_text(page)
119
+
120
+
121
+ def main():
122
+ parser = argparse.ArgumentParser()
123
+ parser.add_argument('--out', type=Path, required=True)
124
+ args = parser.parse_args()
125
+ out = args.out
126
+ results = json.loads((out / 'generations.json').read_text())
127
+ analyzed_file = out / 'analyzed.json'
128
+ existing = json.loads(analyzed_file.read_text()) if analyzed_file.exists() else []
129
+ cached = {(r['source'], r['case'], r['repeat']): r for r in existing}
130
+ analyzed = []
131
+ with httpx.Client(headers={'Authorization': 'Bearer ' + get_token()}, timeout=90, follow_redirects=True) as client:
132
+ for r in results:
133
+ key = r['source'], r['case'], r['repeat']
134
+ if key in cached:
135
+ analyzed.append(cached[key]); continue
136
+ if r.get('audio_file'):
137
+ transcript, details = transcribe(client, out / r['audio_file'])
138
+ else:
139
+ transcript, details = '', []
140
+ r.update(transcript=transcript, transcription_parts=details, **alignment(r['expected'], transcript))
141
+ r['transcription_incomplete'] = any('too short' in part.get('note', '') for part in details)
142
+ r['script_complete'] = not r['transcription_incomplete'] and r['word_coverage'] >= .90 and r['extra_word_fraction'] <= .25
143
+ r['clean_take'] = (r['script_complete'] and r.get('finish_reason') == 'stop' and not r.get('error')
144
+ and r['longest_internal_quiet_s'] <= 2 and r['trailing_quiet_s'] <= 2)
145
+ analyzed.append(r)
146
+ analyzed_file.write_text(json.dumps(analyzed, indent=2) + '\n')
147
+ print(json.dumps({k:r.get(k) for k in ['source','case','repeat','transcript','word_coverage','script_complete','clean_take']}), flush=True)
148
+ summary = summarize(analyzed)
149
+ (out / 'summary.json').write_text(json.dumps(summary, indent=2) + '\n')
150
+ write_report(out, analyzed, summary)
151
+ print('SUMMARY', json.dumps(summary), flush=True)
152
+
153
+
154
+ if __name__ == '__main__':
155
+ main()
endpoint/benchmark_speed.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measure warm streaming speed with unchanged production sampling and references.
2
+
3
+ Run before and after a serving configuration change. Latencies are client-side;
4
+ stream speed excludes the first chunk, but still includes vocoding and transport.
5
+ The model has no request seed, so compare repeated runs rather than take lengths.
6
+ """
7
+ import argparse
8
+ import base64
9
+ import json
10
+ from pathlib import Path
11
+ import statistics
12
+ import time
13
+
14
+ import httpx
15
+ from huggingface_hub import HfApi, get_token
16
+
17
+ from compare import SPACE, SETTINGS, audio_metrics
18
+
19
+ TEXT = ("The rain has stopped, and the street is quiet. I left a note on the kitchen table. "
20
+ "Please read it before you leave, then call me when you reach the station.")
21
+
22
+
23
+ def measure(client, url, payload, label, out):
24
+ started = time.perf_counter()
25
+ chunks, pcm, finish, server = [], bytearray(), None, {}
26
+ rate = 44100
27
+ with client.stream('POST', url, json=payload) as response:
28
+ response.raise_for_status()
29
+ headers_s = time.perf_counter() - started
30
+ rate = int(response.headers.get('x-sample-rate', rate))
31
+ for line in response.iter_lines():
32
+ if not line.startswith('data: '):
33
+ continue
34
+ event = json.loads(line[6:])
35
+ kind = event.get('type')
36
+ if kind == 'start':
37
+ rate = event['sample_rate']
38
+ server['start_client_ms'] = round((time.perf_counter() - started) * 1000)
39
+ if kind in {'speech.audio.delta', 'audio'}:
40
+ pcm.extend(base64.b64decode(event.get('audio', event.get('pcm', ''))))
41
+ chunks.append({'at_s': time.perf_counter() - started, 'audio_s': len(pcm) / (rate * 2)})
42
+ if kind in {'speech.audio.done', 'done'}:
43
+ finish = event.get('finish_reason')
44
+ server.update({key: event[key] for key in ['ttfa_ms', 'total_ms', 'queue_ms',
45
+ 'retry_count', 'trimmed_s', 'timings_ms'] if key in event})
46
+ break
47
+ if kind == 'error':
48
+ raise RuntimeError(event.get('message', str(event)))
49
+ wire_bytes = response.num_bytes_downloaded
50
+ http_version = response.http_version
51
+ encoding = response.headers.get('content-encoding', 'identity')
52
+ wall_s = time.perf_counter() - started
53
+ stream_speed = None
54
+ if len(chunks) > 1 and chunks[-1]['at_s'] > chunks[0]['at_s']:
55
+ stream_speed = ((chunks[-1]['audio_s'] - chunks[0]['audio_s']) /
56
+ (chunks[-1]['at_s'] - chunks[0]['at_s']))
57
+ result = {'label': label, 'headers_ms': round(headers_s * 1000),
58
+ 'first_audio_ms': round(chunks[0]['at_s'] * 1000) if chunks else None,
59
+ 'wall_s': round(wall_s, 3), 'finish_reason': finish,
60
+ 'stream_speed_x': round(stream_speed, 3) if stream_speed else None,
61
+ 'chunks': chunks, **server, **audio_metrics(pcm, rate)}
62
+ result.update(wire_bytes=wire_bytes, http_version=http_version, content_encoding=encoding)
63
+ result['overall_speed_x'] = round(result['audio_s'] / wall_s, 3)
64
+ # Keep raw audio for completion/quality checks, rather than timing alone.
65
+ import wave
66
+ with wave.open(str(out / (label + '.wav')), 'wb') as audio:
67
+ audio.setnchannels(1)
68
+ audio.setsampwidth(2)
69
+ audio.setframerate(rate)
70
+ audio.writeframes(pcm)
71
+ return result
72
+
73
+
74
+ def main():
75
+ parser = argparse.ArgumentParser()
76
+ parser.add_argument('--out', type=Path, required=True)
77
+ parser.add_argument('--repeats', type=int, default=4)
78
+ parser.add_argument('--include-space', action='store_true')
79
+ parser.add_argument('--reference-format', choices=['wav', 'flac'], default='wav',
80
+ help='FLAC preserves exact PCM samples while reducing reference upload size.')
81
+ args = parser.parse_args()
82
+ args.out.mkdir(parents=True, exist_ok=True)
83
+ endpoint = HfApi().get_inference_endpoint('drama-3-h200', namespace='HuggingFaceM4')
84
+ if endpoint.status != 'running' or not endpoint.url:
85
+ raise RuntimeError('Endpoint must be running; cold starts are a separate measurement.')
86
+ results = []
87
+ with httpx.Client(headers={'Authorization': 'Bearer ' + get_token()}, timeout=180) as client:
88
+ status = client.get(SPACE + '/api/status').raise_for_status().json()
89
+ voice = next(v for v in status['voices'] if v['id'] == 'clone1')
90
+ reference = client.get(SPACE + '/api/voices/clone1.wav').raise_for_status().content
91
+ if args.reference_format == 'flac':
92
+ import io
93
+ import numpy as np
94
+ import soundfile as sf
95
+ samples, rate = sf.read(io.BytesIO(reference), dtype='int16')
96
+ encoded = io.BytesIO()
97
+ sf.write(encoded, samples, rate, format='FLAC', subtype='PCM_16')
98
+ decoded, decoded_rate = sf.read(io.BytesIO(encoded.getvalue()), dtype='int16')
99
+ if decoded_rate != rate or not np.array_equal(samples, decoded):
100
+ raise RuntimeError('Reference compression changed the audio samples.')
101
+ reference = encoded.getvalue()
102
+ refs = [{'data': base64.b64encode(reference).decode(), 'media_type': 'audio/' + args.reference_format,
103
+ 'text': voice['transcript']}]
104
+ metadata = {'endpoint_model_revision': endpoint.raw['model']['revision'],
105
+ 'container_args': endpoint.raw['model'].get('args'),
106
+ 'settings': SETTINGS, 'text': TEXT, 'space_gpu': status['gpu'],
107
+ 'reference_format': args.reference_format, 'reference_bytes': len(reference),
108
+ 'repeats': args.repeats, 'frame_rate': 44100 / 2048}
109
+ (args.out / 'metadata.json').write_text(json.dumps(metadata, indent=2) + '\n')
110
+ for mode in ['surprise', 'clone']:
111
+ native = {'input': TEXT, 'stream': True, 'response_format': 'pcm', 'stream_format': 'sse',
112
+ 'max_new_tokens': 500, 'references': refs if mode == 'clone' else None, **SETTINGS}
113
+ # The first request for each shape is kept but excluded from warm medians.
114
+ for i in range(args.repeats + 1):
115
+ targets = ['endpoint', 'space'] if args.include_space else ['endpoint']
116
+ for target in targets:
117
+ payload = native if target == 'endpoint' else {
118
+ 'text': TEXT, 'voice': {'mode': 'clone', 'id': 'clone1'} if mode == 'clone'
119
+ else {'mode': 'random'}, **SETTINGS}
120
+ url = endpoint.url + '/v1/audio/speech' if target == 'endpoint' else SPACE + '/api/generate'
121
+ result = measure(client, url, payload, f'{target}-{mode}-{i}', args.out)
122
+ result.update(target=target, mode=mode, warmup=(i == 0))
123
+ results.append(result)
124
+ print(json.dumps({k: v for k, v in result.items() if k != 'chunks'}), flush=True)
125
+ (args.out / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
126
+ summary = {}
127
+ for target in {r['target'] for r in results}:
128
+ warm = [r for r in results if r['target'] == target and not r['warmup']]
129
+ summary[target] = {key: round(statistics.median(r[key] for r in warm if r[key] is not None), 3)
130
+ for key in ['first_audio_ms', 'wall_s', 'stream_speed_x', 'overall_speed_x']}
131
+ summary[target]['stopped_normally'] = sum(r['finish_reason'] == 'stop' for r in warm)
132
+ summary[target]['requests'] = len(warm)
133
+ (args.out / 'summary.json').write_text(json.dumps(summary, indent=2) + '\n')
134
+ print(json.dumps(summary, indent=2), flush=True)
135
+
136
+
137
+ if __name__ == '__main__':
138
+ main()
endpoint/benchmark_transport.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare native endpoint, Space proxy, and the actual Gradio stage event stream."""
2
+ import argparse
3
+ import base64
4
+ import copy
5
+ import json
6
+ from pathlib import Path
7
+ import statistics
8
+ import time
9
+ import uuid
10
+ import wave
11
+
12
+ import httpx
13
+ from huggingface_hub import HfApi, get_token
14
+
15
+ from benchmark_speed import TEXT, SETTINGS, measure, SPACE
16
+
17
+
18
+ def apply_diff(state, value):
19
+ if isinstance(value, dict):
20
+ return value
21
+ state = copy.deepcopy(state)
22
+ for operation, path, item in value:
23
+ if not path:
24
+ state = item
25
+ continue
26
+ parent = state
27
+ for key in path[:-1]:
28
+ parent = parent[key]
29
+ key = path[-1]
30
+ if operation == 'delete':
31
+ del parent[key]
32
+ elif operation == 'append':
33
+ parent[key] += item
34
+ elif operation == 'add' and isinstance(parent, list):
35
+ parent.insert(key, item)
36
+ else:
37
+ parent[key] = item
38
+ return state
39
+
40
+
41
+ def gradio_measure(client, config, label, out):
42
+ fn = next(d for d in config['dependencies'] if d.get('api_name') == 'stage_perform')
43
+ session = uuid.uuid4().hex
44
+ started = time.perf_counter()
45
+ payload = {'fn_index': fn['id'], 'session_hash': session,
46
+ 'data': [TEXT, 'Surprise', 'clone1', '', 'Narrator', '', '', 1, .9, 0, None, []]}
47
+ joined = client.post(SPACE + '/gradio_api/queue/join', json=payload)
48
+ joined.raise_for_status()
49
+ join_ms = (time.perf_counter() - started) * 1000
50
+ event_id = joined.json()['event_id']
51
+ state, first, prepared, messages, decoded_bytes = None, None, None, 0, 0
52
+ with client.stream('GET', SPACE + '/gradio_api/queue/data', params={'session_hash': session}) as response:
53
+ response.raise_for_status()
54
+ for line in response.iter_lines():
55
+ decoded_bytes += len(line)
56
+ if not line.startswith('data: '):
57
+ continue
58
+ event = json.loads(line[6:])
59
+ if event.get('event_id') != event_id:
60
+ continue
61
+ if event.get('success') is False:
62
+ raise RuntimeError(str(event.get('output')))
63
+ output = event.get('output', {}).get('data')
64
+ if output and event['msg'] in {'process_generating', 'process_completed'}:
65
+ messages += 1
66
+ state = apply_diff(state, output[0])
67
+ if state.get('type') == 'error':
68
+ raise RuntimeError(state['message'])
69
+ if prepared is None:
70
+ prepared = time.perf_counter() - started
71
+ if first is None and state.get('packets'):
72
+ first = time.perf_counter() - started
73
+ if event['msg'] == 'process_completed':
74
+ break
75
+ wire_bytes = response.num_bytes_downloaded
76
+ encoding, version = response.headers.get('content-encoding', 'identity'), response.http_version
77
+ if not state or state.get('type') != 'done':
78
+ raise RuntimeError('The Gradio stage did not complete')
79
+ pcm = b''.join(base64.b64decode(p) for p in state['packets'])
80
+ with wave.open(str(out / (label + '.wav')), 'wb') as audio:
81
+ audio.setnchannels(1); audio.setsampwidth(2); audio.setframerate(state['sample_rate']); audio.writeframes(pcm)
82
+ return {'label': label, 'target': 'gradio', 'join_ms': round(join_ms),
83
+ 'preparing_ms': round(prepared * 1000), 'first_audio_ms': round(first * 1000),
84
+ 'wall_s': round(time.perf_counter() - started, 3), 'messages': messages,
85
+ 'wire_bytes': wire_bytes, 'decoded_bytes': decoded_bytes,
86
+ 'http_version': version, 'content_encoding': encoding,
87
+ **{key: state.get(key) for key in ['ttfa_ms', 'total_ms', 'timings_ms', 'audio_s', 'finish_reason']}}
88
+
89
+
90
+ def main():
91
+ parser = argparse.ArgumentParser()
92
+ parser.add_argument('--out', type=Path, required=True)
93
+ parser.add_argument('--repeats', type=int, default=3)
94
+ parser.add_argument('--compression', choices=['gzip', 'identity'], default='gzip')
95
+ args = parser.parse_args()
96
+ args.out.mkdir(parents=True, exist_ok=True)
97
+ api = HfApi()
98
+ endpoint = api.get_inference_endpoint('drama-3-h200', namespace='HuggingFaceM4')
99
+ headers = {'Authorization': 'Bearer ' + get_token(), 'Accept-Encoding': args.compression}
100
+ rows = []
101
+ with httpx.Client(headers=headers, http2=True,
102
+ limits=httpx.Limits(keepalive_expiry=120), timeout=180) as client:
103
+ config = client.get(SPACE + '/config').raise_for_status().json()
104
+ metadata = {'space_revision': api.space_info('andito/Drama-3').sha,
105
+ 'endpoint_args': endpoint.raw['model']['args'], 'settings': SETTINGS,
106
+ 'text': TEXT, 'repeats': args.repeats, 'accept_encoding': args.compression}
107
+ (args.out / 'metadata.json').write_text(json.dumps(metadata, indent=2) + '\n')
108
+ for repeat in range(args.repeats + 1):
109
+ order = ['endpoint', 'proxy', 'gradio'] if repeat % 2 == 0 else ['gradio', 'proxy', 'endpoint']
110
+ for target in order:
111
+ label = f'{target}-{repeat}'
112
+ if target == 'gradio':
113
+ row = gradio_measure(client, config, label, args.out)
114
+ else:
115
+ payload = {'input': TEXT, 'stream': True, 'response_format': 'pcm', 'stream_format': 'sse',
116
+ 'max_new_tokens': 500, **SETTINGS} if target == 'endpoint' else {
117
+ 'text': TEXT, 'voice': {'mode': 'random'}, **SETTINGS}
118
+ url = endpoint.url + '/v1/audio/speech' if target == 'endpoint' else SPACE + '/api/generate'
119
+ row = measure(client, url, payload, label, args.out)
120
+ row['target'] = target
121
+ row.update(repeat=repeat, warmup=(repeat == 0))
122
+ rows.append(row)
123
+ print(json.dumps({k: v for k, v in row.items() if k != 'chunks'}), flush=True)
124
+ (args.out / 'results.json').write_text(json.dumps(rows, indent=2) + '\n')
125
+ summary = {}
126
+ for target in ['endpoint', 'proxy', 'gradio']:
127
+ samples = [r for r in rows if r['target'] == target and not r['warmup']]
128
+ summary[target] = {k: round(statistics.median(r[k] for r in samples), 3)
129
+ for k in ['first_audio_ms', 'wall_s', 'wire_bytes', 'audio_s']}
130
+ (args.out / 'summary.json').write_text(json.dumps(summary, indent=2) + '\n')
131
+ print(json.dumps(summary, indent=2), flush=True)
132
+
133
+
134
+ if __name__ == '__main__':
135
+ main()
endpoint/compare.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Repeat matched scripts on the H200 endpoint and the public Space; keep all audio."""
2
+ import argparse
3
+ import ast
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from pathlib import Path
9
+ import re
10
+ import time
11
+ from types import SimpleNamespace
12
+
13
+ import httpx
14
+ import numpy as np
15
+ import soundfile as sf
16
+ from huggingface_hub import HfApi, get_token
17
+
18
+ SPACE = 'https://andito-drama-3.hf.space'
19
+ CASES = [
20
+ {'id': 'neutral-surprise', 'text': 'Good evening. The lights are on, the room is quiet, and we are ready to begin.', 'voice': {'mode': 'random'}},
21
+ {'id': 'expressive-surprise', 'text': "[sighs] Fine. If we miss it, it is on you. [laughs] Well, maybe that is fair.", 'voice': {'mode': 'random'}},
22
+ {'id': 'long-surprise', 'text': 'The rain has stopped, and the street is quiet. I left a note on the kitchen table. Please read it before you leave, then call me when you reach the station.', 'voice': {'mode': 'random'}},
23
+ {'id': 'clone1', 'text': 'Good evening. The lights are on, the room is quiet, and we are ready to begin.', 'voice': {'mode': 'clone', 'id': 'clone1'}},
24
+ {'id': 'design', 'text': '[softly] I thought you had forgotten. [sighs] I am glad you came back.', 'voice': {'mode': 'design', 'name': 'Narrator', 'description': 'Warm and quiet, a calm adult voice.'}},
25
+ {'id': 'whisper-clone3', 'text': "Promise me <whisper>you will not tell her</whisper>. [pause] Not a word. Not even a hint.", 'voice': {'mode': 'clone', 'id': 'clone3'}},
26
+ ]
27
+ # top_k 4097 is no cap: every main-token candidate. top-k 30 makes takes go silent.
28
+ SETTINGS = {'temperature': 1.0, 'top_p': .9, 'top_k': 4097}
29
+
30
+
31
+ def helpers(source):
32
+ """Use the deployed Space's own text/budget functions, without importing its UI."""
33
+ tree = ast.parse(source)
34
+ names = {'spoken_text', 'token_budget'}
35
+ segments = [ast.get_source_segment(source, node) for node in tree.body
36
+ if isinstance(node, ast.FunctionDef) and node.name in names]
37
+ env = {'re': re, 'math': math, 'MAX_NEW_TOKENS': 1200, 'SAMPLE_RATE': 44100,
38
+ 'DIRECTION_SPAN': re.compile(r'\[[^\[\]\n]*\]|</?[A-Za-z][^<>\n]{0,40}>')}
39
+ exec('from __future__ import annotations\n' + '\n\n'.join(segments), env)
40
+ return env['spoken_text'], env['token_budget']
41
+
42
+
43
+ def audio_metrics(pcm, rate):
44
+ audio = np.frombuffer(pcm, dtype='<i2').astype(np.float32) / 32768
45
+ frame = round(.05 * rate)
46
+ quiet = [np.sqrt(np.mean(audio[i:i+frame] ** 2)) < .003 for i in range(0, len(audio), frame)]
47
+ speech = [i for i, q in enumerate(quiet) if not q]
48
+ leading = speech[0] * .05 if speech else len(quiet) * .05
49
+ trailing = (len(quiet) - speech[-1] - 1) * .05 if speech else len(quiet) * .05
50
+ runs, run = [], 0
51
+ internal = quiet[speech[0]:speech[-1]+1] if speech else []
52
+ for q in internal + [False]:
53
+ if q:
54
+ run += 1
55
+ elif run:
56
+ runs.append(run * .05)
57
+ run = 0
58
+ return {'audio_s': round(len(audio) / rate, 3), 'leading_quiet_s': round(leading, 2),
59
+ 'trailing_quiet_s': round(trailing, 2), 'longest_internal_quiet_s': round(max(runs, default=0), 2),
60
+ 'internal_gaps_over_2s': sum(r > 2 for r in runs),
61
+ 'rms': round(float(np.sqrt(np.mean(audio ** 2))), 5) if len(audio) else 0}
62
+
63
+
64
+ def generate(client, source, endpoint_url, case, native, out):
65
+ started = time.perf_counter()
66
+ audio = bytearray()
67
+ result = {'source': source, 'case': case['id'], 'repeat': case['repeat'], 'expected': case['expected'],
68
+ 'max_new_tokens': native['max_new_tokens'], 'retry_count': 0}
69
+ url = endpoint_url + '/v1/audio/speech' if source == 'endpoint' else SPACE + '/api/generate'
70
+ payload = native if source == 'endpoint' else {'text': case['text'], 'voice': case['voice'], **SETTINGS}
71
+ rate, first = 44100, None
72
+ with client.stream('POST', url, json=payload) as response:
73
+ response.raise_for_status()
74
+ rate = int(response.headers.get('x-sample-rate', rate))
75
+ for raw in response.iter_lines():
76
+ if not raw.startswith('data: '):
77
+ continue
78
+ event = json.loads(raw[6:])
79
+ kind = event.get('type')
80
+ if kind == 'start':
81
+ rate = event['sample_rate']
82
+ if kind == 'retrying':
83
+ result['retry_count'] += 1
84
+ if kind in {'audio', 'speech.audio.delta'}:
85
+ if first is None:
86
+ first = time.perf_counter()
87
+ audio.extend(base64.b64decode(event['pcm'] if source == 'space' else event['audio']))
88
+ if kind in {'done', 'speech.audio.done'}:
89
+ result['finish_reason'] = event.get('finish_reason')
90
+ if source == 'space':
91
+ result.update({k: event[k] for k in ['warning', 'retry_count', 'trimmed_s', 'ttfa_ms', 'queue_ms'] if k in event})
92
+ break
93
+ if kind == 'error':
94
+ result['error'] = event.get('message', str(event))
95
+ break
96
+ result['wall_s'] = round(time.perf_counter() - started, 3)
97
+ result['first_audio_client_ms'] = round((first - started) * 1000) if first else None
98
+ result.update(audio_metrics(bytes(audio), rate))
99
+ if audio:
100
+ filename = f"{case['id']}-{case['repeat']}-{source}.wav"
101
+ sf.write(out / filename, np.frombuffer(audio, dtype='<i2'), rate, subtype='PCM_16')
102
+ result['audio_file'] = filename
103
+ return result
104
+
105
+
106
+ def main():
107
+ parser = argparse.ArgumentParser()
108
+ parser.add_argument('--out', type=Path, required=True)
109
+ parser.add_argument('--repeats', type=int, default=4)
110
+ args = parser.parse_args()
111
+ out = args.out
112
+ out.mkdir(parents=True, exist_ok=True)
113
+ api = HfApi()
114
+ endpoint = api.get_inference_endpoint('drama-3-h200', namespace='HuggingFaceM4')
115
+ if endpoint.status != 'running' or not endpoint.url:
116
+ raise RuntimeError('Endpoint is not running')
117
+ space = api.space_info('andito/Drama-3')
118
+ model = api.model_info('fishaudio/drama-3')
119
+ if endpoint.raw['model']['revision'] != model.sha:
120
+ raise RuntimeError('Model revisions differ; resolve this before comparing')
121
+ results = []
122
+ with httpx.Client(headers={'Authorization': 'Bearer ' + get_token()}, timeout=120, follow_redirects=True) as client:
123
+ status = client.get(SPACE + '/api/status').raise_for_status().json()
124
+ assert status['engine']['state'] == 'ready' and status['load']['active'] == 0
125
+ assert status['transcriber'] == 'ready'
126
+ code = client.get(f'https://huggingface.co/spaces/andito/Drama-3/resolve/{space.sha}/server.py').raise_for_status().text
127
+ spoken, budget = helpers(code)
128
+ opening_buffer = float(re.search(r'^START_BUFFER_S\s*=\s*([\d.]+)', code, re.M).group(1))
129
+ voices = {v['id']: v for v in status['voices']}
130
+ refs = {}
131
+ for name in ['clone1', 'clone3']:
132
+ content = client.get(SPACE + '/api/voices/' + name + '.wav').raise_for_status().content
133
+ refs[name] = content
134
+ metadata = {'space_revision': space.sha, 'endpoint_model_revision': endpoint.raw['model']['revision'],
135
+ 'space_gpu': status['gpu'], 'endpoint_compute': endpoint.raw['compute'],
136
+ 'endpoint_image': endpoint.raw['model']['image'], 'settings': SETTINGS,
137
+ 'reference_sha256': {k: hashlib.sha256(v).hexdigest() for k, v in refs.items()},
138
+ 'repeats': args.repeats,
139
+ 'opening_buffer_s': opening_buffer,
140
+ 'note': f'Space includes trimming, a {opening_buffer:g}-second opening buffer, and up to one retry; native endpoint audio is unprocessed. No seed is exposed by the Space API. Balanced sequential order; each target has the same model revision, script, reference audio, text budget, and sampling settings.'}
141
+ (out / 'metadata.json').write_text(json.dumps(metadata, indent=2) + '\n')
142
+ for repeat in range(1, args.repeats + 1):
143
+ for index, template in enumerate(CASES):
144
+ case = {**template, 'repeat': repeat, 'expected': spoken(template['text'])}
145
+ voice = case['voice']
146
+ char = SimpleNamespace(description=voice['description'], scene=voice.get('scene')) if voice['mode'] == 'design' else None
147
+ tokens = budget(SimpleNamespace(text=case['text'], note=None), SimpleNamespace(character=char))
148
+ input_text = case['text']
149
+ references = None
150
+ if voice['mode'] == 'design':
151
+ input_text = '@@drama3-space@@' + json.dumps({'text': case['text'], 'voice_references': 0, 'history': [],
152
+ 'character': {'name': voice['name'], 'description': voice['description'], 'scene': voice.get('scene')}}, ensure_ascii=False)
153
+ if voice['mode'] == 'clone':
154
+ references = [{'data': base64.b64encode(refs[voice['id']]).decode(), 'media_type': 'audio/wav', 'text': voices[voice['id']]['transcript']}]
155
+ native = {'input': input_text, 'references': references, 'stream': True, 'response_format': 'pcm',
156
+ 'stream_format': 'sse', 'max_new_tokens': tokens, **SETTINGS}
157
+ order = ['space', 'endpoint'] if (repeat + index) % 2 else ['endpoint', 'space']
158
+ for source in order:
159
+ result = generate(client, source, endpoint.url.rstrip('/'), case, native, out)
160
+ results.append(result)
161
+ print(json.dumps({k:result.get(k) for k in ['case','repeat','source','finish_reason','retry_count','audio_s','longest_internal_quiet_s','trailing_quiet_s','error']}), flush=True)
162
+ (out / 'generations.json').write_text(json.dumps(results, indent=2) + '\n')
163
+ print('All matched generations saved', flush=True)
164
+ status = client.get(SPACE + '/api/status').raise_for_status().json()
165
+ print('Final Space load:', status['load'], flush=True)
166
+
167
+
168
+ if __name__ == '__main__':
169
+ main()
endpoint/deploy.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create the single-GPU Drama 3 inference server; reuse matching deployments."""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+ from huggingface_hub import HfApi
6
+
7
+
8
+ def main():
9
+ parser = argparse.ArgumentParser()
10
+ parser.add_argument('--update-serving', action='store_true',
11
+ help='Apply the pinned container arguments to an existing matching endpoint.')
12
+ args = parser.parse_args()
13
+ config = json.loads(Path(__file__).with_name("deployment.json").read_text())
14
+ api = HfApi()
15
+ existing = next((e for e in api.list_inference_endpoints(namespace=config["namespace"])
16
+ if e.name == config["name"]), None)
17
+ if existing is not None:
18
+ compute = existing.raw["compute"]
19
+ if (existing.repository != config["repository"]
20
+ or existing.raw["provider"] != {"vendor": config["vendor"], "region": config["region"]}
21
+ or compute["instanceType"] != config["instance_type"]
22
+ or compute["instanceSize"] != config["instance_size"]):
23
+ raise RuntimeError("The endpoint name belongs to a different configuration; refusing to overwrite it.")
24
+ endpoint = existing
25
+ if args.update_serving:
26
+ model = existing.raw['model']
27
+ image = model.get('image', {}).get('custom', {})
28
+ pinned_image = config['custom_image']
29
+ if (model.get('revision') != config['revision']
30
+ or image.get('url') != pinned_image['url']
31
+ or image.get('port') != pinned_image['port']
32
+ or image.get('healthRoute') != pinned_image['health_route']
33
+ or model.get('command') != config['container_command']):
34
+ raise RuntimeError('Model revision, image, or command differ; review before updating serving flags.')
35
+ if model.get('args') != config['container_args']:
36
+ print('Updating serving arguments on the existing endpoint; hardware and scaling stay the same.', flush=True)
37
+ endpoint = existing.update(container_args=config['container_args'])
38
+ else:
39
+ hardware = api.list_inference_endpoints_hardware(namespace=config["namespace"])
40
+ target = next((h for h in hardware if (h.vendor, h.region, h.instance_type, h.instance_size)
41
+ == (config["vendor"], config["region"], config["instance_type"], config["instance_size"])), None)
42
+ if target is None or target.status != "available" or target.max_accelerators - target.used_accelerators < 1:
43
+ raise RuntimeError("A single GCP H200 is unavailable or exceeds this organization's quota.")
44
+ print(f"Creating {config['namespace']}/{config['name']}: ${target.price_per_hour}/hour while running", flush=True)
45
+ endpoint = api.create_inference_endpoint(**config)
46
+ print(json.dumps({"name": endpoint.name, "namespace": endpoint.namespace,
47
+ "status": endpoint.status, "url": endpoint.url}), flush=True)
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
endpoint/deployment.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "drama-3-h200",
3
+ "namespace": "HuggingFaceM4",
4
+ "repository": "fishaudio/drama-3",
5
+ "revision": "e0a9218aa48888270f06ef1ed9ea67622c0e17d2",
6
+ "framework": "custom",
7
+ "task": "custom",
8
+ "accelerator": "gpu",
9
+ "instance_size": "x1",
10
+ "instance_type": "nvidia-h200",
11
+ "region": "us-south1",
12
+ "vendor": "gcp",
13
+ "type": "authenticated",
14
+ "min_replica": 0,
15
+ "max_replica": 1,
16
+ "scale_to_zero_timeout": 60,
17
+ "custom_image": {
18
+ "url": "registry.hf.space/andito-drama-3@sha256:e4d8bfb5af1f32029a36d7e9d1bafd83d9416cdc15915f71dce14b1853c42d0f",
19
+ "port": 8000,
20
+ "health_route": "/health"
21
+ },
22
+ "container_command": [
23
+ "python3",
24
+ "-m",
25
+ "sglang_omni.cli"
26
+ ],
27
+ "container_args": [
28
+ "serve",
29
+ "--model-path",
30
+ "/repository",
31
+ "--host",
32
+ "0.0.0.0",
33
+ "--port",
34
+ "8000",
35
+ "--tts_engine.factory.max_running_requests",
36
+ "16",
37
+ "--tts_engine.factory.max_new_tokens",
38
+ "1200",
39
+ "--tts_engine.factory.enable_async_decode",
40
+ "true",
41
+ "--tts_engine.factory.async_decode_min_batch_size",
42
+ "1"
43
+ ],
44
+ "tags": [
45
+ "drama-3",
46
+ "tts",
47
+ "gcp",
48
+ "h200"
49
+ ]
50
+ }
endpoint/verify.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verify streaming speech on the deployed server; save audio and metrics locally."""
2
+ import base64
3
+ import io
4
+ import json
5
+ import time
6
+ from pathlib import Path
7
+ import httpx
8
+ import numpy as np
9
+ import soundfile as sf
10
+ from huggingface_hub import HfApi, get_token
11
+
12
+
13
+ def main():
14
+ root = Path(__file__).parent
15
+ config = json.loads((root / 'deployment.json').read_text())
16
+ endpoint = HfApi().get_inference_endpoint(config['name'], namespace=config['namespace'])
17
+ if endpoint.status != 'running' or not endpoint.url:
18
+ raise RuntimeError('The endpoint must be running before verifying speech')
19
+ result = []
20
+ cases = [
21
+ ('plain', 'Good evening. The lights are on, the room is quiet, and we are ready to begin.', None),
22
+ ('expressive', '[softly] I thought you had forgotten. [sighs] I am glad you came back.', None),
23
+ ('clone', 'A new stage, a familiar voice. This is Drama Three on an H two hundred.', 'clone3.wav'),
24
+ ]
25
+ with httpx.Client(headers={'Authorization': 'Bearer ' + get_token()}, timeout=120) as client:
26
+ health = client.get(endpoint.url + '/health')
27
+ health.raise_for_status()
28
+ for name, text, reference in cases:
29
+ payload = {'input': text, 'stream': True, 'response_format': 'pcm', 'stream_format': 'sse',
30
+ 'max_new_tokens': 480, 'temperature': 1.0, 'top_p': .9, 'top_k': 4097}
31
+ if reference:
32
+ voice_root = root.parent / 'voices'
33
+ entry = next(v for v in json.loads((voice_root / 'voices.json').read_text()) if v['file'] == reference)
34
+ payload['references'] = [{'data': base64.b64encode((voice_root / reference).read_bytes()).decode(),
35
+ 'media_type': 'audio/wav', 'text': entry['transcript']}]
36
+ audio = bytearray()
37
+ first = None
38
+ finish = None
39
+ rate = 44100
40
+ started = time.perf_counter()
41
+ with client.stream('POST', endpoint.url + '/v1/audio/speech', json=payload) as response:
42
+ response.raise_for_status()
43
+ rate = int(response.headers.get('x-sample-rate', rate))
44
+ for raw in response.iter_lines():
45
+ if not raw.startswith('data: '):
46
+ continue
47
+ event = json.loads(raw[6:])
48
+ if event.get('type') == 'error':
49
+ raise RuntimeError(event)
50
+ if event.get('type') == 'speech.audio.delta':
51
+ if first is None:
52
+ first = time.perf_counter()
53
+ audio.extend(base64.b64decode(event['audio']))
54
+ if event.get('type') == 'speech.audio.done':
55
+ finish = event.get('finish_reason')
56
+ break
57
+ samples = np.frombuffer(audio, dtype='<i2').astype(np.float32) / 32768
58
+ if samples.size == 0 or float(np.sqrt(np.mean(samples ** 2))) < .003:
59
+ raise RuntimeError(f'{name} produced no audible speech')
60
+ sf.write(root / (name + '.wav'), samples, rate, subtype='PCM_16')
61
+ elapsed = time.perf_counter() - started
62
+ metrics = {'case': name, 'finish_reason': finish, 'audio_s': round(samples.size / rate, 2),
63
+ 'first_audio_ms': round((first - started) * 1000), 'wall_time_s': round(elapsed, 2)}
64
+ result.append(metrics)
65
+ print(json.dumps(metrics), flush=True)
66
+ (root / 'verification.json').write_text(json.dumps(result, indent=2) + '\n')
67
+ print('Speech streaming and voice cloning verified', flush=True)
68
+
69
+
70
+ if __name__ == '__main__':
71
+ main()
endpoint/watch.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Watch this deployment without exposing credentials or endpoint secrets."""
2
+ import json
3
+ import time
4
+ from pathlib import Path
5
+ import httpx
6
+ from huggingface_hub import HfApi, get_token
7
+
8
+
9
+ def main():
10
+ root = Path(__file__).parent
11
+ config = json.loads((root / 'deployment.json').read_text())
12
+ api = HfApi()
13
+ last = None
14
+ deadline = time.monotonic() + 2400
15
+ with httpx.Client(headers={'Authorization': 'Bearer ' + get_token()}, timeout=30) as client:
16
+ while time.monotonic() < deadline:
17
+ endpoint = api.get_inference_endpoint(config['name'], namespace=config['namespace'])
18
+ state = str(endpoint.status)
19
+ summary = {'name': endpoint.name, 'namespace': endpoint.namespace, 'status': state,
20
+ 'url': endpoint.url, 'provider': endpoint.raw.get('provider'),
21
+ 'compute': endpoint.raw.get('compute'), 'deployment_status': endpoint.raw.get('status')}
22
+ (root / 'status.json').write_text(json.dumps(summary, indent=2) + '\n')
23
+ if state != last:
24
+ print(json.dumps(summary), flush=True)
25
+ last = state
26
+ if state in {'failed', 'updateFailed'}:
27
+ raise RuntimeError('Endpoint deployment failed; inspect the deployment status and logs')
28
+ if state == 'running' and endpoint.url:
29
+ response = client.get(endpoint.url.rstrip('/') + '/health')
30
+ if response.status_code == 200:
31
+ print('Speech server health check passed', flush=True)
32
+ return
33
+ time.sleep(30)
34
+ raise RuntimeError('Timed out waiting for the speech server')
35
+
36
+
37
+ if __name__ == '__main__':
38
+ main()
entitlements.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve demo quota tiers from Hugging Face OAuth identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import hashlib
7
+ import logging
8
+ import os
9
+ import threading
10
+ from typing import Any, Callable
11
+
12
+
13
+ logger = logging.getLogger("drama3_space.entitlements")
14
+
15
+ _DEFAULT_UNLIMITED_ORGS = {"huggingfacem4"}
16
+ _profile_cache: dict[str, dict[str, Any]] = {}
17
+ _profile_cache_lock = threading.Lock()
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Entitlement:
22
+ tier: str
23
+ is_pro: bool
24
+ is_team_member: bool
25
+
26
+ @property
27
+ def unlimited(self) -> bool:
28
+ return self.is_pro or self.is_team_member
29
+
30
+
31
+ def _field(obj: Any, name: str, default: Any = None) -> Any:
32
+ if obj is None:
33
+ return default
34
+ if isinstance(obj, dict):
35
+ return obj.get(name, default)
36
+ return getattr(obj, name, default)
37
+
38
+
39
+ def _unlimited_orgs() -> set[str]:
40
+ """Return case-insensitive org names whose members have unlimited usage."""
41
+ raw = " ".join(
42
+ (
43
+ os.environ.get("UNLIMITED_ORGS", ""),
44
+ os.environ.get("DEMO_UNLIMITED_ORGS", ""),
45
+ )
46
+ )
47
+ configured = {
48
+ value.strip().lower()
49
+ for value in raw.replace(",", " ").split()
50
+ if value.strip()
51
+ }
52
+ return _DEFAULT_UNLIMITED_ORGS | configured
53
+
54
+
55
+ def _oauth_token(oauth_info: Any) -> str | None:
56
+ token = _field(oauth_info, "access_token")
57
+ if token is None:
58
+ return None
59
+ return str(token).strip() or None
60
+
61
+
62
+ def _profile_cache_key(token: str) -> str:
63
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
64
+
65
+
66
+ def _whoami_via_token(token: str) -> dict[str, Any]:
67
+ """Fetch and cache the authenticated Hub ``whoami-v2`` profile."""
68
+ cache_key = _profile_cache_key(token)
69
+ with _profile_cache_lock:
70
+ cached = _profile_cache.get(cache_key)
71
+ if cached is not None:
72
+ return cached
73
+
74
+ try:
75
+ import httpx
76
+
77
+ response = httpx.get(
78
+ "https://huggingface.co/api/whoami-v2",
79
+ headers={"Authorization": f"Bearer {token}"},
80
+ timeout=5.0,
81
+ )
82
+ response.raise_for_status()
83
+ profile = response.json()
84
+ if not isinstance(profile, dict):
85
+ raise ValueError("whoami-v2 returned a non-object response")
86
+ except Exception as exc: # pragma: no cover - network/permission dependent
87
+ logger.info("whoami-v2 profile lookup failed: %r", exc)
88
+ return {}
89
+
90
+ with _profile_cache_lock:
91
+ _profile_cache[cache_key] = profile
92
+ return profile
93
+
94
+
95
+ def _org_names(user: Any, profile: dict[str, Any]) -> set[str]:
96
+ names: set[str] = set()
97
+ org_sources = (_field(user, "orgs", []) or [], profile.get("orgs", []) or [])
98
+ for orgs in org_sources:
99
+ for org in orgs:
100
+ for key in ("preferred_username", "name", "fullname", "sub"):
101
+ value = _field(org, key)
102
+ if value:
103
+ names.add(str(value).lower())
104
+ return names
105
+
106
+
107
+ _PAID_ORG_PLANS = {"team", "enterprise", "enterprise_plus"}
108
+
109
+
110
+ def _in_paid_org(user: Any, profile: dict[str, Any]) -> bool:
111
+ """Member of an org on a Team or Enterprise plan ("PRO and up")."""
112
+ for orgs in (_field(user, "orgs", []) or [], profile.get("orgs", []) or []):
113
+ for org in orgs:
114
+ if _field(org, "isEnterprise") or str(_field(org, "plan", "")).lower() in _PAID_ORG_PLANS:
115
+ return True
116
+ return False
117
+
118
+
119
+ def resolve_entitlement(
120
+ oauth_info: Any,
121
+ *,
122
+ profile_lookup: Callable[[str], dict[str, Any]] | None = None,
123
+ ) -> Entitlement:
124
+ """Resolve ``free``, ``pro``, or ``team`` from a Space OAuth session."""
125
+ user = _field(oauth_info, "user_info")
126
+ token = _oauth_token(oauth_info)
127
+ profile: dict[str, Any] = {}
128
+ if token:
129
+ profile = (profile_lookup or _whoami_via_token)(token)
130
+
131
+ is_pro = bool(_field(user, "is_pro", False) or _field(profile, "isPro", False))
132
+ is_team_member = bool(_unlimited_orgs() & _org_names(user, profile)) or _in_paid_org(user, profile)
133
+ tier = "pro" if is_pro else "team" if is_team_member else "free"
134
+ return Entitlement(tier=tier, is_pro=is_pro, is_team_member=is_team_member)
fish_speech/LICENSE DELETED
@@ -1,114 +0,0 @@
1
- # FISH AUDIO COMMUNITY LICENSE AGREEMENT
2
-
3
- **Version 1.0 — Last Updated: October 13, 2026**
4
-
5
- ## I. INTRODUCTION
6
-
7
- This Agreement applies to any individual person or entity ("You", "Your" or "Licensee") that uses or distributes any portion or element of the Fish Audio Materials or Derivative Works thereof for any Research, Non-Commercial, or Commercial purpose. Capitalized terms not otherwise defined herein are defined in Section V below.
8
-
9
- This Agreement is intended to allow, free of charge, research and non-commercial uses of the Fish Audio Materials and, as set out in Section III, limited uses for a Commercial Purpose by Licensees whose Annual Revenue is less than the Revenue Threshold. Any other use of the Fish Audio Materials for a Commercial Purpose, and any Hosted Service, requires a separate written license agreement from Fish Audio.
10
-
11
- By clicking "I Accept" or by using, distributing, or accessing any portion or element of the Fish Audio Materials or Derivative Works, You agree that You have read, understood and are bound by the terms of this Agreement. If You are acting on behalf of a company, organization or other entity, then "You" includes you and that entity, and You agree that You: (i) are an authorized representative of such entity with the authority to bind such entity to this Agreement, and (ii) You agree to the terms of this Agreement on that entity's behalf.
12
-
13
- ## II. RESEARCH & NON-COMMERCIAL USE LICENSE
14
-
15
- Subject to the terms of this Agreement, Fish Audio grants You a non-exclusive, worldwide, non-transferable, non-sublicensable, revocable and royalty-free limited license under Fish Audio's intellectual property or other rights owned by Fish Audio embodied in the Fish Audio Materials to use, reproduce, distribute, and create Derivative Works of, and make modifications to, the Fish Audio Materials for any Research or Non-Commercial Purpose.
16
-
17
- "Research Purpose" means academic or scientific advancement, and in each case, is not primarily intended for commercial advantage or monetary compensation to You or others.
18
-
19
- "Non-Commercial Purpose" means any purpose other than a Research Purpose that is not primarily intended for commercial advantage or monetary compensation to You or others, such as personal use (i.e., hobbyist) or evaluation and testing.
20
-
21
- ## III. COMMERCIAL USE
22
-
23
- **(a) Community Commercial License.** Subject to the terms of this Agreement (including Sections III(b) through III(d)), Fish Audio grants You a non-exclusive, worldwide, non-transferable, non-sublicensable, revocable and royalty-free limited license under Fish Audio's intellectual property or other rights owned by Fish Audio embodied in the Fish Audio Materials to use, reproduce, distribute, and create Derivative Works of, and make modifications to, the Fish Audio Materials for a Commercial Purpose, but only while Your Annual Revenue is less than USD 500,000 (the "Revenue Threshold").
24
-
25
- "Annual Revenue" means, as of any date, the total gross revenue of You and Your Affiliates, in aggregate, from all sources (whether or not derived from the Fish Audio Materials or Derivative Works) for the twelve (12) months ending on that date, with amounts in other currencies converted into U.S. dollars at a published market exchange rate in effect on that date. If You deploy, install, host, or operate the Fish Audio Materials or Derivative Works for a particular third party, or deliver a copy of them to a third party, for use in that third party's products, services, or internal operations (for example, as a contractor, integrator, consultant, or on-premises vendor), the license in this Section III(a) extends to that work only if that third party's Annual Revenue, calculated as if that third party were You, is also less than the Revenue Threshold. This does not apply to (1) delivering to a third party only output (such as audio files) that You generate Yourself, for example as an agency, studio, or freelancer producing voiceovers, dubbing, or audiobooks for a client, or (2) providing Your own generally available product or service to customers or end users who do not receive a copy of the Fish Audio Materials or Derivative Works; such customers and end users do not need to meet the Revenue Threshold for that use.
26
-
27
- **(b) Reaching the Revenue Threshold.** If, while You are using the Fish Audio Materials or Derivative Works under Section III(a), Your Annual Revenue reaches or exceeds the Revenue Threshold (the date it first does so, the "Threshold Date"), You must notify Fish Audio in writing at business@fish.audio within thirty (30) days after the Threshold Date. Notwithstanding Section III(a), the license in Section III(a) continues to apply to the uses for a Commercial Purpose that You were making on the Threshold Date for ninety (90) days after the Threshold Date (the "Transition Period"). After the Transition Period, any use for a Commercial Purpose requires a separate written license agreement from Fish Audio; however, if Your Annual Revenue later falls below the Revenue Threshold, Section III(a) applies to You again from that date. Reaching the Revenue Threshold is not a breach of this Agreement and does not affect Your license under Section II or Your ownership of outputs under Section IV(c)(iii).
28
-
29
- **(c) Hosted Services.** Notwithstanding Sections II and III(a) and the definition of Commercial Purpose, and regardless of Your purpose or Annual Revenue, no license is granted under this Agreement to offer a Hosted Service, which requires a separate written license agreement from Fish Audio. "Hosted Service" means making the Fish Audio Materials or Derivative Works available to third parties so that they can generate speech or other audio from text, audio, or voice samples that they supply, whether through an application programming interface, software development kit, model-inference or model-hosting service, or a website, application, or feature whose primary purpose is text-to-speech, voiceover, voice generation, or voice cloning. None of the following is a Hosted Service: (1) generating speech for content that You create, select, or control, or that Your own product generates (for example, narration, dubbing, game or app characters, or an assistant's spoken replies), and delivering that speech in or with Your products or services; (2) a production service in which You operate the Fish Audio Materials or Derivative Works, direct each generation, and deliver only finished output to Your client (for example, as an agency, studio, or freelancer producing voiceovers, dubbing, or audiobooks), without giving the client self-serve or automated access; or (3) a free, non-commercial research or demonstration service (for example, a Hugging Face Space, a research demo, or a shared server for a research group) that is not monetized and is not offered or promoted as a production service or API.
30
-
31
- **(d) Other Commercial Use.** **Any use of the Fish Audio Materials or Derivative Works for a Commercial Purpose that is not licensed under Section III(a) (including as continued under Section III(b)) requires a separate written license agreement from Fish Audio.**
32
-
33
- "Commercial Purpose" means any purpose other than a Research Purpose or Non-Commercial Purpose that is primarily intended for or directed toward commercial advantage or monetary compensation to You or others, including but not limited to: (i) creating, modifying, or distributing Your product or service, including via a hosted service or application programming interface, (ii) Your business's or organization's internal operations, and (iii) any use in connection with a product or service for which You charge a fee or generate revenue, whether directly or indirectly.
34
-
35
- To obtain a commercial license, please contact Fish Audio at:
36
-
37
- - **Website:** [https://fish.audio](https://fish.audio)
38
- - **Email:** business@fish.audio
39
-
40
- ## IV. GENERAL TERMS
41
-
42
- All licenses granted under this Agreement, including Your Research, Non-Commercial, and Commercial licenses, are subject to the following terms.
43
-
44
- ### a. Distribution & Attribution
45
-
46
- If You distribute a copy of the Fish Audio Materials or a Derivative Work to a third party, You shall (i) provide a copy of this Agreement to that third party, and (ii) retain the following attribution notice within a "Notice" text file distributed as a part of such copies: "These Fish Audio Materials are licensed under the Fish Audio Community License, Version 1.0. Copyright © 39 AI, INC. All Rights Reserved." If You distribute or make available the Fish Audio Materials or a Derivative Work, or a product or service that uses any portion of them, You shall prominently display "Built with Fish Audio" on a related website, user interface, blogpost, about page, or product documentation.
47
-
48
- If You create a Derivative Work, You may add your own attribution notice(s) to the "Notice" text file included with that Derivative Work, provided that You clearly indicate which attributions apply to the Fish Audio Materials and state in the "Notice" text file that You changed the Fish Audio Materials and how it was modified.
49
-
50
- ### b. Use Restrictions
51
-
52
- Your use of the Fish Audio Materials and Derivative Works, including any output or results of the Fish Audio Materials or Derivative Works, must comply with applicable laws and regulations (including Trade Control Laws and equivalent regulations).
53
-
54
- You will not use the Fish Audio Materials or Derivative Works, or any output or results of the Fish Audio Materials or Derivative Works, to: (i) generate speech that imitates the voice of an identifiable natural person without that person's prior consent (or, for a minor, the consent of a parent or legal guardian, and for a deceased person, the consent of the holder of the relevant rights) or another legal right to do so; (ii) impersonate any person or organization in order to deceive, defraud, or harass, or to place calls in violation of applicable telemarketing or robocall laws; (iii) generate sexual content involving a minor or a voice presented as a minor's, or using the voice of any real person without that person's consent; or (iv) intentionally remove, disable, circumvent, or obscure any watermark or provenance signal applied by the Fish Audio Materials (ordinary editing, mixing, compression, or format conversion of output that is not intended to remove such a signal is not a breach of this clause (iv)). You will not distribute any tool, Derivative Work, or service designed to remove, disable, circumvent, or obscure any such watermark or provenance signal. If You make voice cloning or other speech generation using the Fish Audio Materials or Derivative Works available to third parties, You must bind them, by enforceable terms, to restrictions at least as protective as those in this Section IV(b).
55
-
56
- Furthermore, You will not use the Fish Audio Materials or Derivative Works, or any output or results of the Fish Audio Materials or Derivative Works, to create, train, distill, or improve any foundational generative AI model or any speech, audio, or voice generation model (excluding the Models or Derivative Works). This does not restrict training models solely to detect synthetic audio, verify watermarks, or prevent spoofing.
57
-
58
- ### c. Intellectual Property
59
-
60
- **(i) Trademark License.** No trademark licenses are granted under this Agreement. In connection with the Fish Audio Materials or Derivative Works, You may not use any name or mark owned by or associated with Fish Audio or any of its Affiliates, except (A) as required under Section IV(a), and (B) to make truthful, factual references to Fish Audio and the Models, including naming a Derivative Work model in a way that identifies its origin (for example, "Drama-3-GGUF" or "based on Fish Audio Drama 3"), provided that You do not use any Fish Audio logo, do not use a Fish Audio name or mark as the name of Your company or of any product or service other than such a Derivative Work model, and do not state or imply that Fish Audio sponsors, endorses, or officially released You, Your products, or Your Derivative Works.
61
-
62
- **(ii) Ownership of Derivative Works.** As between You and Fish Audio, You are the owner of Derivative Works You create, subject to Fish Audio's ownership of the Fish Audio Materials and any Derivative Works made by or for Fish Audio.
63
-
64
- **(iii) Ownership of Outputs.** As between You and Fish Audio, You own any outputs generated from the Models or Derivative Works to the extent permitted by applicable law.
65
-
66
- **(iv) Disputes.** If You or Your Affiliate(s) institute litigation or other proceedings against Fish Audio (including a cross-claim or counterclaim in a lawsuit) alleging that the Fish Audio Materials, Derivative Works or associated outputs or results, or any portion of any of the foregoing, constitutes infringement of intellectual property or other rights owned or licensable by You, then any licenses granted to You under this Agreement shall terminate as of the date such litigation or claim is filed or instituted. You will indemnify and hold harmless Fish Audio from and against any claim by any third party arising out of or related to Your use or distribution of the Fish Audio Materials or Derivative Works in violation of this Agreement.
67
-
68
- **(v) Feedback.** From time to time, You may provide Fish Audio with verbal and/or written suggestions, comments or other feedback related to Fish Audio's existing or prospective technology, products or services (collectively, "Feedback"). You are not obligated to provide Fish Audio with Feedback, but to the extent that You do, You hereby grant Fish Audio a perpetual, irrevocable, royalty-free, fully-paid, sub-licensable, transferable, non-exclusive, worldwide right and license to exploit the Feedback in any manner without restriction. Your Feedback is provided "AS IS" and You make no warranties whatsoever about any Feedback.
69
-
70
- ### d. Disclaimer of Warranty
71
-
72
- UNLESS REQUIRED BY APPLICABLE LAW, THE FISH AUDIO MATERIALS AND ANY OUTPUT AND RESULTS THEREFROM ARE PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. YOU ARE SOLELY RESPONSIBLE FOR DETERMINING THE APPROPRIATENESS OR LAWFULNESS OF USING OR REDISTRIBUTING THE FISH AUDIO MATERIALS, DERIVATIVE WORKS OR ANY OUTPUT OR RESULTS AND ASSUME ANY RISKS ASSOCIATED WITH YOUR USE OF THE FISH AUDIO MATERIALS, DERIVATIVE WORKS AND ANY OUTPUT AND RESULTS.
73
-
74
- ### e. Limitation of Liability
75
-
76
- IN NO EVENT WILL FISH AUDIO OR ITS AFFILIATES BE LIABLE UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, TORT, NEGLIGENCE, PRODUCTS LIABILITY, OR OTHERWISE, ARISING OUT OF THIS AGREEMENT, FOR ANY LOST PROFITS OR ANY DIRECT, INDIRECT, SPECIAL, CONSEQUENTIAL, INCIDENTAL, EXEMPLARY OR PUNITIVE DAMAGES, EVEN IF FISH AUDIO OR ITS AFFILIATES HAVE BEEN ADVISED OF THE POSSIBILITY OF ANY OF THE FOREGOING.
77
-
78
- ### f. Term and Termination
79
-
80
- The term of this Agreement will commence upon Your acceptance of this Agreement or access to the Fish Audio Materials and will continue in full force and effect until terminated in accordance with the terms and conditions herein. Fish Audio may terminate this Agreement by written notice to You if You breach any term or condition of this Agreement and, if the breach is capable of cure, You do not cure it within thirty (30) days after Fish Audio's written notice describing the breach. If You breach Section IV(b), this Agreement and all licenses granted to You under it terminate automatically, without notice or cure period. Upon termination of this Agreement, You shall delete and cease use of any Fish Audio Materials or Derivative Works. Sections IV(b) (as it applies to any output or results of the Fish Audio Materials or Derivative Works that You retain), IV(c)(iii), IV(c)(iv), IV(c)(v), IV(d), IV(e), IV(g) and V shall survive the termination of this Agreement.
81
-
82
- ### g. Governing Law
83
-
84
- This Agreement will be governed by and construed in accordance with the laws of the United States and the State of California without regard to choice of law principles, and the UN Convention on Contracts for International Sale of Goods does not apply to this Agreement.
85
-
86
- ### h. Versions
87
-
88
- Fish Audio may publish revised versions of the Fish Audio Community License, each identified by a version number. Fish Audio Materials are licensed under the version with which Fish Audio released them, as stated in the license file accompanying them or on Fish Audio's official model page for them. A revised version applies only to Fish Audio Materials that Fish Audio releases or designates under it, and does not reduce the rights You hold under the version You received. You may elect to use Fish Audio Materials under a later version that Fish Audio designates as applicable to them.
89
-
90
- ### i. Separate Agreements
91
-
92
- If You and Fish Audio have signed a separate written agreement covering the Fish Audio Materials, that agreement governs to the extent it conflicts with this Agreement.
93
-
94
- ## V. DEFINITIONS
95
-
96
- **"Affiliate(s)"** means any entity that directly or indirectly controls, is controlled by, or is under common control with the subject entity; for purposes of this definition, "control" means direct or indirect ownership or control of more than 50% of the voting interests of the subject entity.
97
-
98
- **"Agreement"** means this Fish Audio Community License Agreement, Version 1.0 (also referred to as the "Fish Audio Community License, Version 1.0" or "FACL v1.0").
99
-
100
- **"Annual Revenue"** and **"Revenue Threshold"** have the meanings given in Section III(a), and **"Hosted Service"** has the meaning given in Section III(c).
101
-
102
- **"Derivative Work(s)"** means (a) any derivative work of the Fish Audio Materials as recognized by U.S. copyright laws and (b) any modification of a Model, including any model initialized from all or part of a Model's weights (such as fine-tuned, quantized, merged, and low-rank adaptation ("LoRA") models), whether or not it was trained using output of a Model or Derivative Work. Derivative Works do not include the output of any Model or Derivative Work, or any other model trained or distilled using such output.
103
-
104
- **"Documentation"** means any specifications, manuals, model cards, documentation, and other written information that Fish Audio makes available under this Agreement related to the Software or Models.
105
-
106
- **"Fish Audio"** or **"we"** means 39 AI, INC. and its Affiliates.
107
-
108
- **"Model(s)"** means the machine-learning models, including trained model weights and other elements of the foregoing, that Fish Audio makes available under this Agreement, including Fish Audio Drama 3 and, solely for use with Fish Audio Drama 3 and its Derivative Works, the audio codec that Fish Audio provides for use with Fish Audio Drama 3 (including the copy of that codec distributed with Fish Audio S2-Pro).
109
-
110
- **"Software"** means Fish Audio's proprietary software made available under this Agreement now or in the future, including the Fish Speech source code (https://github.com/fishaudio/fish-speech).
111
-
112
- **"Fish Audio Materials"** means, collectively, the Models, Software and Documentation (and any portion or combination thereof) made available under this Agreement. Model weights that Fish Audio makes available under a different license (for example, the Fish Audio S2-Pro weights under the Fish Audio Research License) are not Fish Audio Materials, even if the Software can run them, except as stated in the definition of Model(s) for the audio codec.
113
-
114
- **"Trade Control Laws"** means any applicable U.S. and non-U.S. export control and trade sanctions laws and regulations.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/__init__.py DELETED
@@ -1 +0,0 @@
1
-
 
 
fish_speech/codec.py DELETED
@@ -1,113 +0,0 @@
1
- """Builds the Drama 3 / S2-Pro codec (configs/modded_dac_vq.yaml) without Hydra,
2
- and mirrors load_codec_model / encode_audio / decode_to_audio from
3
- fish_speech/models/text2semantic/inference.py."""
4
-
5
- from functools import partial
6
-
7
- import torch
8
- from torch.nn.utils.parametrize import is_parametrized, remove_parametrizations
9
-
10
- from fish_speech.models.dac.modded_dac import DAC, ModelArgs, WindowLimitedTransformer
11
- from fish_speech.models.dac.rvq import DownsampleResidualVectorQuantize
12
-
13
-
14
- def _rvq_transformer() -> WindowLimitedTransformer:
15
- return WindowLimitedTransformer(
16
- causal=True,
17
- window_size=128,
18
- input_dim=1024,
19
- config=ModelArgs(
20
- block_size=2048,
21
- n_layer=8,
22
- n_head=16,
23
- dim=1024,
24
- intermediate_size=3072,
25
- n_local_heads=-1,
26
- head_dim=64,
27
- rope_base=10000,
28
- norm_eps=1e-5,
29
- dropout_rate=0.1,
30
- attn_dropout_rate=0.1,
31
- channels_first=True,
32
- ),
33
- )
34
-
35
-
36
- def build_codec() -> DAC:
37
- quantizer = DownsampleResidualVectorQuantize(
38
- input_dim=1024,
39
- n_codebooks=9,
40
- codebook_size=1024,
41
- codebook_dim=8,
42
- quantizer_dropout=0.5,
43
- downsample_factor=[2, 2],
44
- post_module=_rvq_transformer(),
45
- pre_module=_rvq_transformer(),
46
- semantic_codebook_size=4096,
47
- )
48
- return DAC(
49
- sample_rate=44100,
50
- encoder_dim=64,
51
- encoder_rates=[2, 4, 8, 8],
52
- decoder_dim=1536,
53
- decoder_rates=[8, 8, 4, 2],
54
- encoder_transformer_layers=[0, 0, 0, 4],
55
- decoder_transformer_layers=[4, 0, 0, 0],
56
- quantizer=quantizer,
57
- transformer_general_config=partial(
58
- ModelArgs,
59
- block_size=8192,
60
- n_local_heads=-1,
61
- head_dim=64,
62
- rope_base=10000,
63
- norm_eps=1e-5,
64
- dropout_rate=0.1,
65
- attn_dropout_rate=0.1,
66
- channels_first=True,
67
- ),
68
- )
69
-
70
-
71
- @torch.no_grad()
72
- def load_codec(path: str, dtype: torch.dtype = torch.bfloat16) -> DAC:
73
- codec = build_codec()
74
- state_dict = torch.load(path, map_location="cpu", mmap=False, weights_only=True)
75
- if "state_dict" in state_dict:
76
- state_dict = state_dict["state_dict"]
77
- if any("generator" in k for k in state_dict):
78
- state_dict = {
79
- k.replace("generator.", ""): v
80
- for k, v in state_dict.items()
81
- if "generator." in k
82
- }
83
- rebuilt_buffers = {
84
- f"{name}.{buffer}" if name else buffer
85
- for name, module in codec.named_modules()
86
- for buffer in module._non_persistent_buffers_set
87
- }
88
- state_dict = {k: v for k, v in state_dict.items() if k not in rebuilt_buffers}
89
- codec.load_state_dict(state_dict, strict=True)
90
- codec.eval()
91
- for module in list(codec.modules()):
92
- if is_parametrized(module, "weight"):
93
- remove_parametrizations(module, "weight", leave_parametrized=True)
94
- elif hasattr(module, "weight_g"):
95
- torch.nn.utils.remove_weight_norm(module)
96
- return codec.to(dtype=dtype)
97
-
98
-
99
- @torch.no_grad()
100
- def encode(codec: DAC, wav: torch.Tensor) -> torch.Tensor:
101
- """Mono float waveform at codec.sample_rate -> codes [num_codebooks, T]."""
102
- device = next(codec.parameters()).device
103
- dtype = next(codec.parameters()).dtype
104
- audios = wav.to(device=device, dtype=dtype)[None, None]
105
- lengths = torch.tensor([wav.shape[-1]], device=device, dtype=torch.long)
106
- indices, feature_lengths = codec.encode(audios, lengths)
107
- return indices[0, :, : feature_lengths[0]]
108
-
109
-
110
- @torch.no_grad()
111
- def decode(codec: DAC, codes: torch.Tensor) -> torch.Tensor:
112
- device = next(codec.parameters()).device
113
- return codec.from_indices(codes.to(device)[None])[0, 0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/__init__.py DELETED
@@ -1 +0,0 @@
1
-
 
 
fish_speech/models/dac/__init__.py DELETED
@@ -1 +0,0 @@
1
-
 
 
fish_speech/models/dac/dac_nn.py DELETED
@@ -1,285 +0,0 @@
1
- # Vendored from descript-audio-codec (MIT License, Copyright (c) 2023-present Descript):
2
- # dac/nn/layers.py and dac/nn/quantize.py, with TorchScript removed from snake().
3
- from typing import Union
4
-
5
- import numpy as np
6
- import torch
7
- import torch.nn as nn
8
- import torch.nn.functional as F
9
- from einops import rearrange
10
- from torch.nn.utils import weight_norm
11
-
12
-
13
- def WNConv1d(*args, **kwargs):
14
- return weight_norm(nn.Conv1d(*args, **kwargs))
15
-
16
-
17
- def WNConvTranspose1d(*args, **kwargs):
18
- return weight_norm(nn.ConvTranspose1d(*args, **kwargs))
19
-
20
-
21
- # Scripting this brings model speed up 1.4x
22
-
23
- def snake(x, alpha):
24
- shape = x.shape
25
- x = x.reshape(shape[0], shape[1], -1)
26
- x = x + (alpha + 1e-9).reciprocal() * torch.sin(alpha * x).pow(2)
27
- x = x.reshape(shape)
28
- return x
29
-
30
-
31
- class Snake1d(nn.Module):
32
- def __init__(self, channels):
33
- super().__init__()
34
- self.alpha = nn.Parameter(torch.ones(1, channels, 1))
35
-
36
- def forward(self, x):
37
- return snake(x, self.alpha)
38
-
39
-
40
-
41
-
42
-
43
- class VectorQuantize(nn.Module):
44
- """
45
- Implementation of VQ similar to Karpathy's repo:
46
- https://github.com/karpathy/deep-vector-quantization
47
- Additionally uses following tricks from Improved VQGAN
48
- (https://arxiv.org/pdf/2110.04627.pdf):
49
- 1. Factorized codes: Perform nearest neighbor lookup in low-dimensional space
50
- for improved codebook usage
51
- 2. l2-normalized codes: Converts euclidean distance to cosine similarity which
52
- improves training stability
53
- """
54
-
55
- def __init__(self, input_dim: int, codebook_size: int, codebook_dim: int):
56
- super().__init__()
57
- self.codebook_size = codebook_size
58
- self.codebook_dim = codebook_dim
59
-
60
- self.in_proj = WNConv1d(input_dim, codebook_dim, kernel_size=1)
61
- self.out_proj = WNConv1d(codebook_dim, input_dim, kernel_size=1)
62
- self.codebook = nn.Embedding(codebook_size, codebook_dim)
63
-
64
- def forward(self, z):
65
- """Quantized the input tensor using a fixed codebook and returns
66
- the corresponding codebook vectors
67
-
68
- Parameters
69
- ----------
70
- z : Tensor[B x D x T]
71
-
72
- Returns
73
- -------
74
- Tensor[B x D x T]
75
- Quantized continuous representation of input
76
- Tensor[1]
77
- Commitment loss to train encoder to predict vectors closer to codebook
78
- entries
79
- Tensor[1]
80
- Codebook loss to update the codebook
81
- Tensor[B x T]
82
- Codebook indices (quantized discrete representation of input)
83
- Tensor[B x D x T]
84
- Projected latents (continuous representation of input before quantization)
85
- """
86
-
87
- # Factorized codes (ViT-VQGAN) Project input into low-dimensional space
88
- z_e = self.in_proj(z) # z_e : (B x D x T)
89
- z_q, indices = self.decode_latents(z_e)
90
-
91
- commitment_loss = F.mse_loss(z_e, z_q.detach(), reduction="none").mean([1, 2])
92
- codebook_loss = F.mse_loss(z_q, z_e.detach(), reduction="none").mean([1, 2])
93
-
94
- z_q = (
95
- z_e + (z_q - z_e).detach()
96
- ) # noop in forward pass, straight-through gradient estimator in backward pass
97
-
98
- z_q = self.out_proj(z_q)
99
-
100
- return z_q, commitment_loss, codebook_loss, indices, z_e
101
-
102
- def embed_code(self, embed_id):
103
- return F.embedding(embed_id, self.codebook.weight)
104
-
105
- def decode_code(self, embed_id):
106
- return self.embed_code(embed_id).transpose(1, 2)
107
-
108
- def decode_latents(self, latents):
109
- encodings = rearrange(latents, "b d t -> (b t) d")
110
- codebook = self.codebook.weight # codebook: (N x D)
111
-
112
- # L2 normalize encodings and codebook (ViT-VQGAN)
113
- encodings = F.normalize(encodings)
114
- codebook = F.normalize(codebook)
115
-
116
- # Compute euclidean distance with codebook
117
- dist = (
118
- encodings.pow(2).sum(1, keepdim=True)
119
- - 2 * encodings @ codebook.t()
120
- + codebook.pow(2).sum(1, keepdim=True).t()
121
- )
122
- indices = rearrange((-dist).max(1)[1], "(b t) -> b t", b=latents.size(0))
123
- z_q = self.decode_code(indices)
124
- return z_q, indices
125
-
126
-
127
- class ResidualVectorQuantize(nn.Module):
128
- """
129
- Introduced in SoundStream: An end2end neural audio codec
130
- https://arxiv.org/abs/2107.03312
131
- """
132
-
133
- def __init__(
134
- self,
135
- input_dim: int = 512,
136
- n_codebooks: int = 9,
137
- codebook_size: int = 1024,
138
- codebook_dim: Union[int, list] = 8,
139
- quantizer_dropout: float = 0.0,
140
- ):
141
- super().__init__()
142
- if isinstance(codebook_dim, int):
143
- codebook_dim = [codebook_dim for _ in range(n_codebooks)]
144
-
145
- self.n_codebooks = n_codebooks
146
- self.codebook_dim = codebook_dim
147
- self.codebook_size = codebook_size
148
-
149
- self.quantizers = nn.ModuleList(
150
- [
151
- VectorQuantize(input_dim, codebook_size, codebook_dim[i])
152
- for i in range(n_codebooks)
153
- ]
154
- )
155
- self.quantizer_dropout = quantizer_dropout
156
-
157
- def forward(self, z, n_quantizers: int = None):
158
- """Quantized the input tensor using a fixed set of `n` codebooks and returns
159
- the corresponding codebook vectors
160
- Parameters
161
- ----------
162
- z : Tensor[B x D x T]
163
- n_quantizers : int, optional
164
- No. of quantizers to use
165
- (n_quantizers < self.n_codebooks ex: for quantizer dropout)
166
- Note: if `self.quantizer_dropout` is True, this argument is ignored
167
- when in training mode, and a random number of quantizers is used.
168
- Returns
169
- -------
170
- dict
171
- A dictionary with the following keys:
172
-
173
- "z" : Tensor[B x D x T]
174
- Quantized continuous representation of input
175
- "codes" : Tensor[B x N x T]
176
- Codebook indices for each codebook
177
- (quantized discrete representation of input)
178
- "latents" : Tensor[B x N*D x T]
179
- Projected latents (continuous representation of input before quantization)
180
- "vq/commitment_loss" : Tensor[1]
181
- Commitment loss to train encoder to predict vectors closer to codebook
182
- entries
183
- "vq/codebook_loss" : Tensor[1]
184
- Codebook loss to update the codebook
185
- """
186
- z_q = 0
187
- residual = z
188
- commitment_loss = 0
189
- codebook_loss = 0
190
-
191
- codebook_indices = []
192
- latents = []
193
-
194
- if n_quantizers is None:
195
- n_quantizers = self.n_codebooks
196
- if self.training:
197
- n_quantizers = torch.ones((z.shape[0],)) * self.n_codebooks + 1
198
- dropout = torch.randint(1, self.n_codebooks + 1, (z.shape[0],))
199
- n_dropout = int(z.shape[0] * self.quantizer_dropout)
200
- n_quantizers[:n_dropout] = dropout[:n_dropout]
201
- n_quantizers = n_quantizers.to(z.device)
202
-
203
- for i, quantizer in enumerate(self.quantizers):
204
- if self.training is False and i >= n_quantizers:
205
- break
206
-
207
- z_q_i, commitment_loss_i, codebook_loss_i, indices_i, z_e_i = quantizer(
208
- residual
209
- )
210
-
211
- # Create mask to apply quantizer dropout
212
- mask = (
213
- torch.full((z.shape[0],), fill_value=i, device=z.device) < n_quantizers
214
- )
215
- z_q = z_q + z_q_i * mask[:, None, None]
216
- residual = residual - z_q_i
217
-
218
- # Sum losses
219
- commitment_loss += (commitment_loss_i * mask).mean()
220
- codebook_loss += (codebook_loss_i * mask).mean()
221
-
222
- codebook_indices.append(indices_i)
223
- latents.append(z_e_i)
224
-
225
- codes = torch.stack(codebook_indices, dim=1)
226
- latents = torch.cat(latents, dim=1)
227
-
228
- return z_q, codes, latents, commitment_loss, codebook_loss
229
-
230
- def from_codes(self, codes: torch.Tensor):
231
- """Given the quantized codes, reconstruct the continuous representation
232
- Parameters
233
- ----------
234
- codes : Tensor[B x N x T]
235
- Quantized discrete representation of input
236
- Returns
237
- -------
238
- Tensor[B x D x T]
239
- Quantized continuous representation of input
240
- """
241
- z_q = 0.0
242
- z_p = []
243
- n_codebooks = codes.shape[1]
244
- for i in range(n_codebooks):
245
- z_p_i = self.quantizers[i].decode_code(codes[:, i, :])
246
- z_p.append(z_p_i)
247
-
248
- z_q_i = self.quantizers[i].out_proj(z_p_i)
249
- z_q = z_q + z_q_i
250
- return z_q, torch.cat(z_p, dim=1), codes
251
-
252
- def from_latents(self, latents: torch.Tensor):
253
- """Given the unquantized latents, reconstruct the
254
- continuous representation after quantization.
255
-
256
- Parameters
257
- ----------
258
- latents : Tensor[B x N x T]
259
- Continuous representation of input after projection
260
-
261
- Returns
262
- -------
263
- Tensor[B x D x T]
264
- Quantized representation of full-projected space
265
- Tensor[B x D x T]
266
- Quantized representation of latent space
267
- """
268
- z_q = 0
269
- z_p = []
270
- codes = []
271
- dims = np.cumsum([0] + [q.codebook_dim for q in self.quantizers])
272
-
273
- n_codebooks = np.where(dims <= latents.shape[1])[0].max(axis=0, keepdims=True)[
274
- 0
275
- ]
276
- for i in range(n_codebooks):
277
- j, k = dims[i], dims[i + 1]
278
- z_p_i, codes_i = self.quantizers[i].decode_latents(latents[:, j:k, :])
279
- z_p.append(z_p_i)
280
- codes.append(codes_i)
281
-
282
- z_q_i = self.quantizers[i].out_proj(z_p_i)
283
- z_q = z_q + z_q_i
284
-
285
- return z_q, torch.cat(z_p, dim=1), torch.stack(codes, dim=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/dac/modded_dac.py DELETED
@@ -1,991 +0,0 @@
1
- import math
2
- import typing as tp
3
- from dataclasses import dataclass
4
- from typing import List, Optional, Union
5
-
6
- import numpy as np
7
- import torch
8
- from fish_speech.models.dac.dac_nn import Snake1d, WNConv1d, WNConvTranspose1d
9
- from torch import Tensor, nn
10
- from torch.nn import functional as F
11
- from torch.nn.utils.parametrizations import weight_norm
12
- from torch.nn.utils.parametrize import remove_parametrizations
13
-
14
-
15
- @dataclass
16
- class VQResult:
17
- z: torch.Tensor
18
- codes: torch.Tensor
19
- latents: torch.Tensor
20
- codebook_loss: torch.Tensor
21
- commitment_loss: torch.Tensor
22
- semantic_distill_z: torch.Tensor | None = None
23
-
24
-
25
- def find_multiple(n: int, k: int) -> int:
26
- if n % k == 0:
27
- return n
28
- return n + k - (n % k)
29
-
30
-
31
- @dataclass
32
- class ModelArgs:
33
- block_size: int = 2048
34
- n_layer: int = 8
35
- n_head: int = 8
36
- dim: int = 512
37
- intermediate_size: int = 1536
38
- n_local_heads: int = -1
39
- head_dim: int = 64
40
- rope_base: float = 10000
41
- norm_eps: float = 1e-5
42
- dropout_rate: float = 0.1
43
- attn_dropout_rate: float = 0.1
44
- channels_first: bool = True # to be compatible with conv1d input/output
45
- pos_embed_type: str = "rope" # can be "rope" or "conformer"
46
- max_relative_position: int = 128 # for conformer-style relative position embedding
47
- window_size: int = 512 # for window limited attention
48
-
49
- def __post_init__(self):
50
- if self.n_local_heads == -1:
51
- self.n_local_heads = self.n_head
52
- if self.intermediate_size is None:
53
- hidden_dim = 4 * self.dim
54
- n_hidden = int(2 * hidden_dim / 3)
55
- self.intermediate_size = find_multiple(n_hidden, 256)
56
- assert self.pos_embed_type in [
57
- "rope",
58
- "conformer",
59
- ], "pos_embed_type must be either 'rope' or 'conformer'"
60
-
61
-
62
- class KVCache(nn.Module):
63
- def __init__(
64
- self, max_batch_size, max_seq_length, n_heads, head_dim, dtype=torch.bfloat16
65
- ):
66
- super().__init__()
67
- cache_shape = (max_batch_size, n_heads, max_seq_length, head_dim)
68
- self.register_buffer("k_cache", torch.zeros(cache_shape, dtype=dtype))
69
- self.register_buffer("v_cache", torch.zeros(cache_shape, dtype=dtype))
70
-
71
- def update(self, input_pos, k_val, v_val):
72
- # input_pos: [S], k_val: [B, H, S, D]
73
- assert input_pos.shape[0] == k_val.shape[2]
74
-
75
- k_out = self.k_cache
76
- v_out = self.v_cache
77
- k_out[:, :, input_pos] = k_val
78
- v_out[:, :, input_pos] = v_val
79
-
80
- return (
81
- k_out[:, :, : input_pos.max() + 1, :],
82
- v_out[:, :, : input_pos.max() + 1, :],
83
- )
84
-
85
- def clear_cache(self, prompt_len):
86
- self.k_cache[:, :, prompt_len:, :] = torch.zeros_like(
87
- self.k_cache[:, :, prompt_len:, :]
88
- )
89
- self.v_cache[:, :, prompt_len:, :] = torch.zeros_like(
90
- self.v_cache[:, :, prompt_len:, :]
91
- )
92
-
93
-
94
- class Transformer(nn.Module):
95
- def __init__(self, config: ModelArgs) -> None:
96
- super().__init__()
97
- self.config = config
98
-
99
- self.layers = nn.ModuleList(
100
- TransformerBlock(config) for _ in range(config.n_layer)
101
- )
102
- self.norm = RMSNorm(config.dim, eps=config.norm_eps)
103
-
104
- # Only compute RoPE frequencies if using RoPE
105
- if config.pos_embed_type == "rope":
106
- freqs_cis = precompute_freqs_cis(
107
- 327680, self.config.head_dim, self.config.rope_base
108
- )
109
- self.register_buffer("freqs_cis", freqs_cis, persistent=False)
110
- else:
111
- self.register_buffer("freqs_cis", None)
112
-
113
- causal_mask = torch.tril(torch.ones(32768, 32768, dtype=torch.bool))
114
- self.register_buffer("causal_mask", causal_mask, persistent=False)
115
-
116
- self.max_batch_size = -1
117
- self.max_seq_length = -1
118
- self.use_kv_cache = False
119
-
120
- def setup_caches(self, max_batch_size, max_seq_length):
121
- """
122
- This method will only be called during inference when using KV cache.
123
- """
124
- head_dim = self.config.dim // self.config.n_head
125
- max_seq_length = find_multiple(max_seq_length, 8)
126
- self.max_seq_length = max_seq_length
127
- self.max_batch_size = max_batch_size
128
- dtype = self.norm.weight.dtype
129
- device = self.norm.weight.device
130
-
131
- for b in self.layers:
132
- b.attention.kv_cache = KVCache(
133
- max_batch_size,
134
- max_seq_length,
135
- self.config.n_local_heads,
136
- head_dim,
137
- dtype,
138
- ).to(device)
139
-
140
- self.use_kv_cache = True
141
-
142
- def forward(
143
- self,
144
- x: Tensor,
145
- input_pos: Optional[Tensor] = None,
146
- mask: Optional[Tensor] = None,
147
- ) -> Tensor:
148
- if self.config.pos_embed_type == "rope":
149
- assert (
150
- self.freqs_cis is not None
151
- ), "RoPE frequencies must be initialized for RoPE positional embedding"
152
- # print("MAX", input_pos.max())
153
- freqs_cis = self.freqs_cis[input_pos]
154
- else:
155
- freqs_cis = None
156
-
157
- if mask is None: # in case of non-causal model
158
- if not self.training and self.use_kv_cache:
159
- mask = self.causal_mask[None, None, input_pos]
160
- mask = mask[..., : input_pos.max() + 1]
161
- else:
162
- mask = self.causal_mask[None, None, input_pos]
163
- mask = mask[..., input_pos]
164
-
165
- for i, layer in enumerate(self.layers):
166
- x = layer(x, input_pos, freqs_cis, mask)
167
- x = self.norm(x)
168
- return x
169
-
170
-
171
- class TransformerBlock(nn.Module):
172
- def __init__(self, config: ModelArgs) -> None:
173
- super().__init__()
174
- self.attention = Attention(config)
175
- self.feed_forward = FeedForward(config)
176
- self.ffn_norm = RMSNorm(config.dim, eps=config.norm_eps)
177
- self.attention_norm = RMSNorm(config.dim, eps=config.norm_eps)
178
- self.attention_layer_scale = LayerScale(config.dim, inplace=True)
179
- self.ffn_layer_scale = LayerScale(config.dim, inplace=True)
180
-
181
- def forward(
182
- self,
183
- x: Tensor,
184
- input_pos: Tensor,
185
- freqs_cis: Tensor,
186
- mask: Tensor,
187
- ) -> Tensor:
188
- h = x + self.attention_layer_scale(
189
- self.attention(self.attention_norm(x), freqs_cis, mask, input_pos)
190
- )
191
- out = h + self.ffn_layer_scale(self.feed_forward(self.ffn_norm(h)))
192
- return out
193
-
194
-
195
- class Attention(nn.Module):
196
- def __init__(self, config: ModelArgs):
197
- super().__init__()
198
- assert config.dim % config.n_head == 0
199
-
200
- total_head_dim = (config.n_head + 2 * config.n_local_heads) * config.head_dim
201
- # key, query, value projections for all heads, but in a batch
202
- self.wqkv = nn.Linear(config.dim, total_head_dim, bias=False)
203
- self.wo = nn.Linear(config.head_dim * config.n_head, config.dim, bias=False)
204
- self.kv_cache = None
205
-
206
- self.n_head = config.n_head
207
- self.head_dim = config.head_dim
208
- self.n_local_heads = config.n_local_heads
209
- self.dim = config.dim
210
- self.attn_dropout_rate = config.attn_dropout_rate
211
- self.pos_embed_type = config.pos_embed_type
212
-
213
- # Add relative position embedding for conformer-style
214
- if self.pos_embed_type == "conformer":
215
- self.max_relative_position = config.max_relative_position
216
- num_pos_embeddings = 2 * config.max_relative_position + 1
217
- self.rel_pos_embeddings = nn.Parameter(
218
- torch.zeros(num_pos_embeddings, self.head_dim)
219
- )
220
- nn.init.normal_(self.rel_pos_embeddings, mean=0.0, std=0.02)
221
-
222
- def _compute_conformer_pos_scores(self, q: Tensor, seqlen: int) -> Tensor:
223
- # q: [B, H, S, D]
224
- # Returns: [B, H, S, S]
225
- positions = torch.arange(seqlen, device=q.device)
226
- relative_positions = positions.unsqueeze(1) - positions.unsqueeze(0) # [S, S]
227
- relative_positions = torch.clamp(
228
- relative_positions + self.max_relative_position,
229
- 0,
230
- 2 * self.max_relative_position,
231
- )
232
- rel_embeddings = self.rel_pos_embeddings[relative_positions] # [S, S, D]
233
-
234
- # Compute attention scores with relative position embeddings
235
- q = q.transpose(1, 2) # [B, S, H, D]
236
- rel_logits = torch.matmul(q, rel_embeddings.transpose(-2, -1)) # [B, S, H, S]
237
- rel_logits = rel_logits.transpose(1, 2) # [B, H, S, S]
238
- return rel_logits
239
-
240
- def forward(
241
- self,
242
- x: Tensor,
243
- freqs_cis: Tensor,
244
- mask: Tensor,
245
- input_pos: Optional[Tensor] = None,
246
- ) -> Tensor:
247
- bsz, seqlen, _ = x.shape
248
-
249
- kv_size = self.n_local_heads * self.head_dim
250
- q, k, v = self.wqkv(x).split([kv_size, kv_size, kv_size], dim=-1)
251
- context_seqlen = seqlen
252
-
253
- q = q.view(bsz, seqlen, self.n_head, self.head_dim)
254
- k = k.view(bsz, context_seqlen, self.n_local_heads, self.head_dim)
255
- v = v.view(bsz, context_seqlen, self.n_local_heads, self.head_dim)
256
-
257
- if self.pos_embed_type == "rope":
258
- q = apply_rotary_emb(q, freqs_cis)
259
- k = apply_rotary_emb(k, freqs_cis)
260
-
261
- q, k, v = map(lambda x: x.transpose(1, 2), (q, k, v))
262
-
263
- if self.kv_cache is not None:
264
- k, v = self.kv_cache.update(input_pos, k, v)
265
-
266
- k = k.repeat_interleave(self.n_head // self.n_local_heads, dim=1)
267
- v = v.repeat_interleave(self.n_head // self.n_local_heads, dim=1)
268
-
269
- if self.pos_embed_type == "conformer":
270
- # Compute attention scores
271
- scale = 1.0 / math.sqrt(self.head_dim)
272
- scores = torch.matmul(q, k.transpose(-2, -1)) * scale
273
-
274
- # Add relative position embeddings for conformer-style
275
- rel_scores = self._compute_conformer_pos_scores(q, seqlen)
276
- scores = scores + rel_scores
277
-
278
- # Apply attention
279
- if mask is not None:
280
- scores = scores.masked_fill(~mask, float("-inf"))
281
-
282
- attn = F.softmax(scores, dim=-1)
283
- if self.attn_dropout_rate > 0 and self.training:
284
- attn = F.dropout(attn, p=self.attn_dropout_rate)
285
-
286
- y = torch.matmul(attn, v)
287
- else:
288
- y = F.scaled_dot_product_attention(
289
- q,
290
- k,
291
- v,
292
- dropout_p=self.attn_dropout_rate if self.training else 0.0,
293
- attn_mask=mask,
294
- )
295
- # is_causal=True)
296
- y = (
297
- y.transpose(1, 2)
298
- .contiguous()
299
- .view(bsz, seqlen, self.head_dim * self.n_head)
300
- )
301
- y = self.wo(y)
302
- return y
303
-
304
-
305
- class FeedForward(nn.Module):
306
- def __init__(self, config: ModelArgs) -> None:
307
- super().__init__()
308
- self.w1 = nn.Linear(config.dim, config.intermediate_size, bias=False)
309
- self.w3 = nn.Linear(config.dim, config.intermediate_size, bias=False)
310
- self.w2 = nn.Linear(config.intermediate_size, config.dim, bias=False)
311
- self.dropout = nn.Dropout(config.dropout_rate)
312
-
313
- def forward(self, x: Tensor) -> Tensor:
314
- return self.w2(self.dropout(F.silu(self.w1(x)) * self.w3(x)))
315
-
316
-
317
- class RMSNorm(nn.Module):
318
- def __init__(self, dim: int, eps: float = 1e-5):
319
- super().__init__()
320
- self.eps = eps
321
- self.weight = nn.Parameter(torch.ones(dim))
322
-
323
- def _norm(self, x):
324
- return x * torch.rsqrt(torch.mean(x * x, dim=-1, keepdim=True) + self.eps)
325
-
326
- def forward(self, x: Tensor) -> Tensor:
327
- output = self._norm(x.float()).type_as(x)
328
- return output * self.weight
329
-
330
-
331
- class LayerScale(nn.Module):
332
- def __init__(
333
- self,
334
- dim: int,
335
- init_values: Union[float, Tensor] = 1e-2,
336
- inplace: bool = False,
337
- ) -> None:
338
- super().__init__()
339
- self.inplace = inplace
340
- self.gamma = nn.Parameter(init_values * torch.ones(dim))
341
-
342
- def forward(self, x: Tensor) -> Tensor:
343
- return x.mul_(self.gamma) if self.inplace else x * self.gamma
344
-
345
-
346
- class WindowLimitedTransformer(Transformer):
347
- """
348
- Transformer with window limited attention, causal.
349
- """
350
-
351
- def __init__(
352
- self,
353
- config: ModelArgs,
354
- input_dim: int = 512,
355
- window_size: Optional[int] = None,
356
- causal: bool = True,
357
- look_ahead_conv: nn.Module = None,
358
- ):
359
- super().__init__(config)
360
- self.window_size = window_size
361
- self.causal = causal
362
- self.channels_first = config.channels_first
363
- self.look_ahead_conv = (
364
- look_ahead_conv if look_ahead_conv is not None else nn.Identity()
365
- )
366
- self.input_proj = (
367
- nn.Linear(input_dim, config.dim)
368
- if input_dim != config.dim
369
- else nn.Identity()
370
- )
371
- self.output_proj = (
372
- nn.Linear(config.dim, input_dim)
373
- if input_dim != config.dim
374
- else nn.Identity()
375
- )
376
-
377
- def make_window_limited_mask(
378
- self,
379
- max_length: int,
380
- x_lens: Optional[Tensor] = None,
381
- ) -> Tensor:
382
- """
383
- Make mask to form window limited attention.
384
- """
385
- if self.causal:
386
- mask = torch.tril(torch.ones(max_length, max_length))
387
- row_indices = torch.arange(max_length).view(-1, 1)
388
- window_size = self.window_size or max_length
389
- valid_range = (row_indices - window_size + 1).clamp(min=0)
390
- column_indices = torch.arange(max_length)
391
- mask = (column_indices >= valid_range) & mask.bool()
392
- else:
393
- raise NotImplementedError
394
- mask = mask.bool()[None, None]
395
- return mask
396
-
397
- def make_mask(
398
- self,
399
- max_length: int,
400
- x_lens: Optional[Tensor] = None,
401
- ) -> Tensor:
402
- """
403
- Make ordinary mask if window size is not specified.
404
- """
405
- if self.causal:
406
- mask = torch.tril(torch.ones(max_length, max_length))
407
- else:
408
- mask = torch.ones(max_length, max_length)
409
- mask = mask.bool()[None, None]
410
- for i, x_len in enumerate(x_lens):
411
- mask[:x_len, i] = 0
412
- mask = mask.bool()[None, None]
413
- return mask
414
-
415
- def forward(
416
- self,
417
- x: Tensor,
418
- x_lens: Optional[Tensor] = None,
419
- ) -> Tensor:
420
- if self.channels_first:
421
- x = x.transpose(1, 2)
422
- x = self.input_proj(x) # (B, T, D)
423
- x = self.look_ahead_conv(x)
424
- input_pos = torch.arange(x.shape[1], device=x.device)
425
- # construct mask to form window limited attention
426
- max_length = x.shape[1]
427
- if self.window_size is not None:
428
- mask = self.make_window_limited_mask(max_length, x_lens)
429
- else:
430
- mask = self.make_mask(max_length, x_lens)
431
- mask = mask.to(x.device)
432
- x = super().forward(x, input_pos, mask)
433
- x = self.output_proj(x) # (B, T, D)
434
- if self.channels_first:
435
- x = x.transpose(1, 2)
436
- return x
437
-
438
-
439
- def precompute_freqs_cis(
440
- seq_len: int, n_elem: int, base: int = 10000, dtype: torch.dtype = torch.bfloat16
441
- ) -> Tensor:
442
- freqs = 1.0 / (
443
- base ** (torch.arange(0, n_elem, 2)[: (n_elem // 2)].float() / n_elem)
444
- )
445
- t = torch.arange(seq_len, device=freqs.device)
446
- freqs = torch.outer(t, freqs)
447
- freqs_cis = torch.polar(torch.ones_like(freqs), freqs)
448
- cache = torch.stack([freqs_cis.real, freqs_cis.imag], dim=-1)
449
- return cache.to(dtype=dtype)
450
-
451
-
452
- def apply_rotary_emb(x: Tensor, freqs_cis: Tensor) -> Tensor:
453
- xshaped = x.float().reshape(*x.shape[:-1], -1, 2)
454
- freqs_cis = freqs_cis.view(1, xshaped.size(1), 1, xshaped.size(3), 2)
455
- x_out2 = torch.stack(
456
- [
457
- xshaped[..., 0] * freqs_cis[..., 0] - xshaped[..., 1] * freqs_cis[..., 1],
458
- xshaped[..., 1] * freqs_cis[..., 0] + xshaped[..., 0] * freqs_cis[..., 1],
459
- ],
460
- -1,
461
- )
462
-
463
- x_out2 = x_out2.flatten(3)
464
- return x_out2.type_as(x)
465
-
466
-
467
- def init_weights(m):
468
- if isinstance(m, nn.Conv1d):
469
- nn.init.trunc_normal_(m.weight, std=0.02)
470
- nn.init.constant_(m.bias, 0)
471
-
472
-
473
- def unpad1d(x: torch.Tensor, paddings: tp.Tuple[int, int]):
474
- """Remove padding from x, handling properly zero padding. Only for 1d!"""
475
- padding_left, padding_right = paddings
476
- assert padding_left >= 0 and padding_right >= 0, (padding_left, padding_right)
477
- assert (padding_left + padding_right) <= x.shape[-1]
478
- end = x.shape[-1] - padding_right
479
- return x[..., padding_left:end]
480
-
481
-
482
- def get_extra_padding_for_conv1d(
483
- x: torch.Tensor, kernel_size: int, stride: int, padding_total: int = 0
484
- ) -> int:
485
- """See `pad_for_conv1d`."""
486
- length = x.shape[-1]
487
- n_frames = (length - kernel_size + padding_total) / stride + 1
488
- ideal_length = (math.ceil(n_frames) - 1) * stride + (kernel_size - padding_total)
489
- return ideal_length - length
490
-
491
-
492
- def pad1d(
493
- x: torch.Tensor,
494
- paddings: tp.Tuple[int, int],
495
- mode: str = "zeros",
496
- value: float = 0.0,
497
- ):
498
- """Tiny wrapper around F.pad, just to allow for reflect padding on small input.
499
- If this is the case, we insert extra 0 padding to the right
500
- before the reflection happen.
501
- """
502
- length = x.shape[-1]
503
- padding_left, padding_right = paddings
504
- assert padding_left >= 0 and padding_right >= 0, (padding_left, padding_right)
505
- if mode == "reflect":
506
- max_pad = max(padding_left, padding_right)
507
- extra_pad = 0
508
- if length <= max_pad:
509
- extra_pad = max_pad - length + 1
510
- x = F.pad(x, (0, extra_pad))
511
- padded = F.pad(x, paddings, mode, value)
512
- end = padded.shape[-1] - extra_pad
513
- return padded[..., :end]
514
- else:
515
- return F.pad(x, paddings, mode, value)
516
-
517
-
518
- class CausalConvNet(nn.Module):
519
- def __init__(
520
- self,
521
- in_channels,
522
- out_channels,
523
- kernel_size,
524
- dilation=1,
525
- stride=1,
526
- groups=1,
527
- padding=None,
528
- ):
529
- super(CausalConvNet, self).__init__()
530
- self.conv = nn.Conv1d(
531
- in_channels,
532
- out_channels,
533
- kernel_size,
534
- stride=stride,
535
- dilation=dilation,
536
- groups=groups,
537
- )
538
- self.stride = stride
539
- self.kernel_size = (kernel_size - 1) * dilation + 1
540
- self.dilation = dilation
541
- self.padding = self.kernel_size - self.stride
542
-
543
- def forward(self, x):
544
- pad = self.padding
545
- extra_padding = get_extra_padding_for_conv1d(
546
- x, self.kernel_size, self.stride, pad
547
- )
548
- x = pad1d(x, (pad, extra_padding), mode="constant", value=0)
549
- return self.conv(x).contiguous()
550
-
551
- def weight_norm(self, name="weight", dim=0):
552
- self.conv = weight_norm(self.conv, name=name, dim=dim)
553
- return self
554
-
555
- def remove_weight_norm(self):
556
- self.conv = remove_parametrizations(self.conv)
557
- return self
558
-
559
-
560
- class CausalTransConvNet(nn.Module):
561
- def __init__(
562
- self, in_channels, out_channels, kernel_size, dilation=1, stride=1, padding=None
563
- ):
564
- super(CausalTransConvNet, self).__init__()
565
- self.conv = nn.ConvTranspose1d(
566
- in_channels, out_channels, kernel_size, stride=stride, dilation=dilation
567
- )
568
- self.stride = stride
569
- self.kernel_size = kernel_size
570
-
571
- def forward(self, x):
572
- x = self.conv(x)
573
- pad = self.kernel_size - self.stride
574
- padding_right = math.ceil(pad)
575
- padding_left = pad - padding_right
576
- x = unpad1d(x, (padding_left, padding_right))
577
- return x.contiguous()
578
-
579
- def weight_norm(self, name="weight", dim=0):
580
- self.conv = weight_norm(self.conv, name=name, dim=dim)
581
- return self
582
-
583
- def remove_weight_norm(self):
584
- self.conv = remove_parametrizations(self.conv)
585
- return self
586
-
587
-
588
- def CausalWNConv1d(*args, **kwargs):
589
- return CausalConvNet(*args, **kwargs).weight_norm()
590
-
591
-
592
- def CausalWNConvTranspose1d(*args, **kwargs):
593
- return CausalTransConvNet(*args, **kwargs).weight_norm()
594
-
595
-
596
- class ResidualUnit(nn.Module):
597
- def __init__(self, dim: int = 16, dilation: int = 1, causal: bool = False):
598
- super().__init__()
599
- conv_class = CausalWNConv1d if causal else WNConv1d
600
- pad = ((7 - 1) * dilation) // 2
601
- self.block = nn.Sequential(
602
- Snake1d(dim),
603
- conv_class(dim, dim, kernel_size=7, dilation=dilation, padding=pad),
604
- Snake1d(dim),
605
- conv_class(dim, dim, kernel_size=1),
606
- )
607
- self.causal = causal
608
-
609
- def forward(self, x):
610
- y = self.block(x)
611
- pad = x.shape[-1] - y.shape[-1]
612
- if pad > 0:
613
- if self.causal:
614
- x = x[..., :-pad]
615
- else:
616
- x = x[..., pad // 2 : -pad // 2]
617
- return x + y
618
-
619
-
620
- class EncoderBlock(nn.Module):
621
- def __init__(
622
- self,
623
- dim: int = 16,
624
- stride: int = 1,
625
- causal: bool = False,
626
- n_t_layer: int = 0,
627
- transformer_general_config=None,
628
- ):
629
- super().__init__()
630
- conv_class = CausalWNConv1d if causal else WNConv1d
631
- transformer_module = (
632
- nn.Identity()
633
- if n_t_layer == 0
634
- else (
635
- WindowLimitedTransformer(
636
- causal=causal,
637
- input_dim=dim,
638
- window_size=getattr(transformer_general_config, "window_size", 512),
639
- config=transformer_general_config(
640
- n_layer=n_t_layer,
641
- n_head=dim // 64,
642
- dim=dim,
643
- intermediate_size=dim * 3,
644
- ),
645
- )
646
- )
647
- )
648
- self.block = nn.Sequential(
649
- ResidualUnit(dim // 2, dilation=1, causal=causal),
650
- ResidualUnit(dim // 2, dilation=3, causal=causal),
651
- ResidualUnit(dim // 2, dilation=9, causal=causal),
652
- Snake1d(dim // 2),
653
- conv_class(
654
- dim // 2,
655
- dim,
656
- kernel_size=2 * stride,
657
- stride=stride,
658
- padding=math.ceil(stride / 2),
659
- ),
660
- transformer_module,
661
- )
662
-
663
- def forward(self, x):
664
- return self.block(x)
665
-
666
-
667
- class Encoder(nn.Module):
668
- def __init__(
669
- self,
670
- d_model: int = 64,
671
- strides: list = [2, 4, 8, 8],
672
- d_latent: int = 64,
673
- n_transformer_layers: list = [0, 0, 4, 4],
674
- transformer_general_config: ModelArgs = None,
675
- causal: bool = False,
676
- ):
677
- super().__init__()
678
- conv_class = CausalWNConv1d if causal else WNConv1d
679
- # Create first convolution
680
- self.block = [conv_class(1, d_model, kernel_size=7, padding=3)]
681
-
682
- # Create EncoderBlocks that double channels as they downsample by `stride`
683
- for stride, n_t_layer in zip(strides, n_transformer_layers):
684
- d_model *= 2
685
- self.block += [
686
- EncoderBlock(
687
- d_model,
688
- stride=stride,
689
- causal=causal,
690
- n_t_layer=n_t_layer,
691
- transformer_general_config=transformer_general_config,
692
- )
693
- ]
694
-
695
- # Create last convolution
696
- self.block += [
697
- Snake1d(d_model),
698
- conv_class(d_model, d_latent, kernel_size=3, padding=1),
699
- ]
700
-
701
- # Wrap black into nn.Sequential
702
- self.block = nn.Sequential(*self.block)
703
- self.enc_dim = d_model
704
-
705
- def forward(self, x):
706
- return self.block(x)
707
-
708
-
709
- class DecoderBlock(nn.Module):
710
- def __init__(
711
- self,
712
- input_dim: int = 16,
713
- output_dim: int = 8,
714
- stride: int = 1,
715
- causal: bool = False,
716
- n_t_layer: int = 0,
717
- transformer_general_config=None,
718
- ):
719
- super().__init__()
720
- conv_trans_class = CausalWNConvTranspose1d if causal else WNConvTranspose1d
721
- transformer_module = (
722
- nn.Identity()
723
- if n_t_layer == 0
724
- else (
725
- WindowLimitedTransformer(
726
- causal=causal,
727
- input_dim=input_dim,
728
- window_size=None,
729
- config=transformer_general_config(
730
- n_layer=n_t_layer,
731
- n_head=input_dim // 64,
732
- dim=input_dim,
733
- intermediate_size=input_dim * 3,
734
- ),
735
- )
736
- )
737
- )
738
- self.block = nn.Sequential(
739
- # transformer_module,
740
- Snake1d(input_dim),
741
- conv_trans_class(
742
- input_dim,
743
- output_dim,
744
- kernel_size=2 * stride,
745
- stride=stride,
746
- padding=math.ceil(stride / 2),
747
- ),
748
- ResidualUnit(output_dim, dilation=1, causal=causal),
749
- ResidualUnit(output_dim, dilation=3, causal=causal),
750
- ResidualUnit(output_dim, dilation=9, causal=causal),
751
- )
752
-
753
- def forward(self, x):
754
- return self.block(x)
755
-
756
-
757
- class Decoder(nn.Module):
758
- def __init__(
759
- self,
760
- input_channel,
761
- channels,
762
- rates,
763
- d_out: int = 1,
764
- causal: bool = False,
765
- n_transformer_layers: list = [0, 0, 0, 0],
766
- transformer_general_config=None,
767
- ):
768
- super().__init__()
769
- conv_class = CausalWNConv1d if causal else WNConv1d
770
- # Add first conv layer
771
- layers = [conv_class(input_channel, channels, kernel_size=7, padding=3)]
772
-
773
- # Add upsampling + MRF blocks
774
- for i, (stride, n_t_layer) in enumerate(zip(rates, n_transformer_layers)):
775
- input_dim = channels // 2**i
776
- output_dim = channels // 2 ** (i + 1)
777
- layers += [
778
- DecoderBlock(
779
- input_dim,
780
- output_dim,
781
- stride,
782
- causal=causal,
783
- n_t_layer=n_t_layer,
784
- transformer_general_config=transformer_general_config,
785
- )
786
- ]
787
-
788
- # Add final conv layer
789
- layers += [
790
- Snake1d(output_dim),
791
- conv_class(output_dim, d_out, kernel_size=7, padding=3),
792
- nn.Tanh(),
793
- ]
794
-
795
- self.model = nn.Sequential(*layers)
796
-
797
- def forward(self, x):
798
- return self.model(x)
799
-
800
-
801
- class DAC(nn.Module):
802
- def __init__(
803
- self,
804
- encoder_dim: int = 64,
805
- encoder_rates: List[int] = [2, 4, 8, 8],
806
- latent_dim: int = None,
807
- decoder_dim: int = 1536,
808
- decoder_rates: List[int] = [8, 8, 4, 2],
809
- quantizer: torch.nn.Module = None,
810
- sample_rate: int = 44100,
811
- causal: bool = True,
812
- encoder_transformer_layers: List[int] = [0, 0, 0, 0],
813
- decoder_transformer_layers: List[int] = [0, 0, 0, 0],
814
- overwrite_decoder: torch.nn.Module = None,
815
- transformer_general_config=None,
816
- ):
817
- super().__init__()
818
-
819
- self.encoder_dim = encoder_dim
820
- self.encoder_rates = encoder_rates
821
- self.decoder_dim = decoder_dim
822
- self.decoder_rates = decoder_rates
823
- self.sample_rate = sample_rate
824
-
825
- if latent_dim is None:
826
- latent_dim = encoder_dim * (2 ** len(encoder_rates))
827
-
828
- self.latent_dim = latent_dim
829
-
830
- self.hop_length = np.prod(encoder_rates)
831
- self.encoder = Encoder(
832
- encoder_dim,
833
- encoder_rates,
834
- latent_dim,
835
- causal=causal,
836
- n_transformer_layers=encoder_transformer_layers,
837
- transformer_general_config=transformer_general_config,
838
- )
839
-
840
- self.quantizer = quantizer
841
-
842
- if overwrite_decoder is not None:
843
- self.decoder = overwrite_decoder
844
- else:
845
- self.decoder = Decoder(
846
- latent_dim,
847
- decoder_dim,
848
- decoder_rates,
849
- causal=causal,
850
- n_transformer_layers=decoder_transformer_layers,
851
- transformer_general_config=transformer_general_config,
852
- )
853
- self.sample_rate = sample_rate
854
- self.apply(init_weights)
855
-
856
- self.frame_length = self.hop_length * 4
857
-
858
- def preprocess(self, audio_data, sample_rate):
859
- if sample_rate is None:
860
- sample_rate = self.sample_rate
861
- assert sample_rate == self.sample_rate
862
-
863
- length = audio_data.shape[-1]
864
- right_pad = math.ceil(length / self.hop_length) * self.hop_length - length
865
- audio_data = nn.functional.pad(audio_data, (0, right_pad))
866
-
867
- return audio_data
868
-
869
- def encode(
870
- self,
871
- audio_data: torch.Tensor,
872
- audio_lengths: torch.Tensor = None,
873
- n_quantizers: int = None,
874
- **kwargs,
875
- ):
876
- """Encode given audio data and return quantized latent codes
877
-
878
- Parameters
879
- ----------
880
- audio_data : Tensor[B x T]
881
- Audio data to encode
882
- n_quantizers : int, optional
883
- Number of quantizers to use, by default None
884
- If None, all quantizers are used.
885
-
886
- Returns
887
- -------
888
- dict
889
- A dictionary with the following keys:
890
- "z" : Tensor[B x D x T]
891
- Quantized continuous representation of input
892
- "codes" : Tensor[B x N x T]
893
- Codebook indices for each codebook
894
- (quantized discrete representation of input)
895
- "latents" : Tensor[B x N*D x T]
896
- Projected latents (continuous representation of input before quantization)
897
- "vq/commitment_loss" : Tensor[1]
898
- Commitment loss to train encoder to predict vectors closer to codebook
899
- entries
900
- "vq/codebook_loss" : Tensor[1]
901
- Codebook loss to update the codebook
902
- "length" : int
903
- Number of samples in input audio
904
- """
905
- # pad to multiple of self.frame_length
906
- if audio_data.ndim == 2:
907
- audio_data = audio_data.unsqueeze(1)
908
- length = audio_data.shape[-1]
909
- right_pad = math.ceil(length / self.frame_length) * self.frame_length - length
910
- audio_data = nn.functional.pad(audio_data, (0, right_pad))
911
- if audio_lengths is None:
912
- audio_lengths = torch.LongTensor([length + right_pad]).to(audio_data.device)
913
-
914
- z = self.encoder(audio_data)
915
- vq_results = self.quantizer(z, n_quantizers, **kwargs)
916
- indices = vq_results.codes
917
- indices_lens = torch.ceil(audio_lengths / self.frame_length).long()
918
- return indices, indices_lens
919
-
920
- def from_indices(self, indices: torch.Tensor):
921
- z = self.quantizer.decode(indices)
922
- return self.decoder(z)
923
-
924
- def decode(self, z: torch.Tensor):
925
- """Decode given latent codes and return audio data
926
-
927
- Parameters
928
- ----------
929
- z : Tensor[B x D x T]
930
- Quantized continuous representation of input
931
- length : int, optional
932
- Number of samples in output audio, by default None
933
-
934
- Returns
935
- -------
936
- dict
937
- A dictionary with the following keys:
938
- "audio" : Tensor[B x 1 x length]
939
- Decoded audio data.
940
- """
941
- return self.decoder(z)
942
-
943
- def forward(
944
- self,
945
- audio_data: torch.Tensor,
946
- template: torch.Tensor = None,
947
- mask: torch.Tensor = None,
948
- sample_rate: int = None,
949
- n_quantizers: int = None,
950
- **kwargs,
951
- ):
952
- """Model forward pass
953
-
954
- Parameters
955
- ----------
956
- audio_data : Tensor[B x 1 x T]
957
- Audio data to encode
958
- sample_rate : int, optional
959
- Sample rate of audio data in Hz, by default None
960
- If None, defaults to `self.sample_rate`
961
- n_quantizers : int, optional
962
- Number of quantizers to use, by default None.
963
- If None, all quantizers are used.
964
-
965
- Returns
966
- -------
967
- dict
968
- A dictionary with the following keys:
969
- "z" : Tensor[B x D x T]
970
- Quantized continuous representation of input
971
- "codes" : Tensor[B x N x T]
972
- Codebook indices for each codebook
973
- (quantized discrete representation of input)
974
- "latents" : Tensor[B x N*D x T]
975
- Projected latents (continuous representation of input before quantization)
976
- "vq/commitment_loss" : Tensor[1]
977
- Commitment loss to train encoder to predict vectors closer to codebook
978
- entries
979
- "vq/codebook_loss" : Tensor[1]
980
- Codebook loss to update the codebook
981
- "length" : int
982
- Number of samples in input audio
983
- "audio" : Tensor[B x 1 x length]
984
- Decoded audio data.
985
- """
986
- length = audio_data.shape[-1]
987
- audio_data = self.preprocess(audio_data, sample_rate)
988
- vq_results = self.encode(audio_data, n_quantizers, **kwargs)
989
- z = vq_results[0] if isinstance(vq_results, tuple) else vq_results.z
990
- x = self.decode(z)
991
- return x[..., :length], vq_results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/dac/rvq.py DELETED
@@ -1,371 +0,0 @@
1
- import math
2
- import typing as tp
3
- from dataclasses import dataclass
4
-
5
- import torch
6
- import torch.nn as nn
7
- import torch.nn.functional as F
8
- from fish_speech.models.dac.dac_nn import ResidualVectorQuantize
9
- from torch.nn.utils.parametrizations import weight_norm
10
- from torch.nn.utils.parametrize import remove_parametrizations
11
-
12
-
13
- def unpad1d(x: torch.Tensor, paddings: tp.Tuple[int, int]):
14
- """Remove padding from x, handling properly zero padding. Only for 1d!"""
15
- padding_left, padding_right = paddings
16
- assert padding_left >= 0 and padding_right >= 0, (padding_left, padding_right)
17
- assert (padding_left + padding_right) <= x.shape[-1]
18
- end = x.shape[-1] - padding_right
19
- return x[..., padding_left:end]
20
-
21
-
22
- def get_extra_padding_for_conv1d(
23
- x: torch.Tensor, kernel_size: int, stride: int, padding_total: int = 0
24
- ) -> int:
25
- """See `pad_for_conv1d`."""
26
- length = x.shape[-1]
27
- n_frames = (length - kernel_size + padding_total) / stride + 1
28
- ideal_length = (math.ceil(n_frames) - 1) * stride + (kernel_size - padding_total)
29
- return ideal_length - length
30
-
31
-
32
- def pad1d(
33
- x: torch.Tensor,
34
- paddings: tp.Tuple[int, int],
35
- mode: str = "zeros",
36
- value: float = 0.0,
37
- ):
38
- """Tiny wrapper around F.pad, just to allow for reflect padding on small input.
39
- If this is the case, we insert extra 0 padding to the right
40
- before the reflection happen.
41
- """
42
- length = x.shape[-1]
43
- padding_left, padding_right = paddings
44
- assert padding_left >= 0 and padding_right >= 0, (padding_left, padding_right)
45
- if mode == "reflect":
46
- max_pad = max(padding_left, padding_right)
47
- extra_pad = 0
48
- if length <= max_pad:
49
- extra_pad = max_pad - length + 1
50
- x = F.pad(x, (0, extra_pad))
51
- padded = F.pad(x, paddings, mode, value)
52
- end = padded.shape[-1] - extra_pad
53
- return padded[..., :end]
54
- else:
55
- return F.pad(x, paddings, mode, value)
56
-
57
-
58
- class CausalConvNet(nn.Module):
59
- def __init__(
60
- self,
61
- in_channels,
62
- out_channels,
63
- kernel_size,
64
- dilation=1,
65
- stride=1,
66
- groups=1,
67
- padding=None,
68
- ):
69
- super(CausalConvNet, self).__init__()
70
- self.conv = nn.Conv1d(
71
- in_channels,
72
- out_channels,
73
- kernel_size,
74
- stride=stride,
75
- dilation=dilation,
76
- groups=groups,
77
- )
78
- self.stride = stride
79
- self.kernel_size = (kernel_size - 1) * dilation + 1
80
- self.dilation = dilation
81
- self.padding = self.kernel_size - self.stride
82
-
83
- def forward(self, x):
84
- pad = self.padding
85
- extra_padding = get_extra_padding_for_conv1d(
86
- x, self.kernel_size, self.stride, pad
87
- )
88
- x = pad1d(x, (pad, extra_padding), mode="constant", value=0)
89
- return self.conv(x).contiguous()
90
-
91
- def weight_norm(self, name="weight", dim=0):
92
- self.conv = weight_norm(self.conv, name=name, dim=dim)
93
- return self
94
-
95
- def remove_weight_norm(self):
96
- self.conv = remove_parametrizations(self.conv)
97
- return self
98
-
99
-
100
- class CausalTransConvNet(nn.Module):
101
- def __init__(
102
- self, in_channels, out_channels, kernel_size, dilation=1, stride=1, padding=None
103
- ):
104
- super(CausalTransConvNet, self).__init__()
105
- self.conv = nn.ConvTranspose1d(
106
- in_channels, out_channels, kernel_size, stride=stride, dilation=dilation
107
- )
108
- self.stride = stride
109
- self.kernel_size = kernel_size
110
-
111
- def forward(self, x):
112
- x = self.conv(x)
113
- pad = self.kernel_size - self.stride
114
- padding_right = math.ceil(pad)
115
- padding_left = pad - padding_right
116
- x = unpad1d(x, (padding_left, padding_right))
117
- return x.contiguous()
118
-
119
- def weight_norm(self, name="weight", dim=0):
120
- self.conv = weight_norm(self.conv, name=name, dim=dim)
121
- return self
122
-
123
- def remove_weight_norm(self):
124
- self.conv = remove_parametrizations(self.conv)
125
- return self
126
-
127
-
128
- # ConvNeXt Block copied from https://github.com/fishaudio/fish-diffusion/blob/main/fish_diffusion/modules/convnext.py
129
- class ConvNeXtBlock(nn.Module):
130
- r"""ConvNeXt Block. There are two equivalent implementations:
131
- (1) DwConv -> LayerNorm (channels_first) -> 1x1 Conv -> GELU -> 1x1 Conv; all in (N, C, H, W)
132
- (2) DwConv -> Permute to (N, H, W, C); LayerNorm (channels_last) -> Linear -> GELU -> Linear; Permute back
133
- We use (2) as we find it slightly faster in PyTorch
134
- Args:
135
- dim (int): Number of input channels.
136
- drop_path (float): Stochastic depth rate. Default: 0.0
137
- layer_scale_init_value (float): Init value for Layer Scale. Default: 1e-6.
138
- mlp_ratio (float): Ratio of mlp hidden dim to embedding dim. Default: 4.0.
139
- kernel_size (int): Kernel size for depthwise conv. Default: 7.
140
- dilation (int): Dilation for depthwise conv. Default: 1.
141
- """ # noqa: E501
142
-
143
- def __init__(
144
- self,
145
- dim: int,
146
- layer_scale_init_value: float = 1e-6,
147
- mlp_ratio: float = 4.0,
148
- kernel_size: int = 7,
149
- dilation: int = 1,
150
- ):
151
- super().__init__()
152
- convnet_type = CausalConvNet
153
- self.dwconv = convnet_type(
154
- dim,
155
- dim,
156
- kernel_size=kernel_size,
157
- # padding=int(dilation * (kernel_size - 1) / 2),
158
- groups=dim,
159
- dilation=dilation,
160
- ) # depthwise conv
161
- self.norm = nn.LayerNorm(dim, eps=1e-6)
162
- self.pwconv1 = nn.Linear(
163
- dim, int(mlp_ratio * dim)
164
- ) # pointwise/1x1 convs, implemented with linear layers
165
- self.act = nn.GELU()
166
- self.pwconv2 = nn.Linear(int(mlp_ratio * dim), dim)
167
- self.gamma = (
168
- nn.Parameter(layer_scale_init_value * torch.ones((dim)), requires_grad=True)
169
- if layer_scale_init_value > 0
170
- else None
171
- )
172
-
173
- def forward(self, x, apply_residual: bool = True):
174
- input = x
175
-
176
- x = self.dwconv(x)
177
- x = x.permute(0, 2, 1) # (N, C, L) -> (N, L, C)
178
- x = self.norm(x)
179
- x = self.pwconv1(x)
180
- x = self.act(x)
181
- x = self.pwconv2(x)
182
-
183
- if self.gamma is not None:
184
- x = self.gamma * x
185
-
186
- x = x.permute(0, 2, 1) # (N, L, C) -> (N, C, L)
187
-
188
- if apply_residual:
189
- x = input + x
190
-
191
- return x
192
-
193
-
194
- @dataclass
195
- class VQResult:
196
- z: torch.Tensor
197
- codes: torch.Tensor
198
- latents: torch.Tensor
199
- codebook_loss: torch.Tensor
200
- commitment_loss: torch.Tensor
201
- semantic_distill_z: torch.Tensor | None = None
202
-
203
-
204
- class DownsampleResidualVectorQuantize(nn.Module):
205
- def __init__(
206
- self,
207
- input_dim: int = 1024,
208
- n_codebooks: int = 9,
209
- codebook_dim: int = 8,
210
- quantizer_dropout: float = 0.5,
211
- codebook_size: int = 1024,
212
- semantic_codebook_size: int = 4096,
213
- downsample_factor: tuple[int] = (2, 2),
214
- downsample_dims: tuple[int] | None = None,
215
- pre_module: nn.Module | None = None,
216
- post_module: nn.Module | None = None,
217
- semantic_predictor_module: nn.Module | None = None,
218
- ):
219
- super().__init__()
220
-
221
- if downsample_dims is None:
222
- downsample_dims = [input_dim for _ in range(len(downsample_factor))]
223
-
224
- all_dims = (input_dim,) + tuple(downsample_dims)
225
-
226
- self.semantic_quantizer = ResidualVectorQuantize(
227
- input_dim=input_dim,
228
- n_codebooks=1,
229
- codebook_size=semantic_codebook_size,
230
- codebook_dim=codebook_dim,
231
- quantizer_dropout=0.0,
232
- )
233
-
234
- self.quantizer = ResidualVectorQuantize(
235
- input_dim=input_dim,
236
- n_codebooks=n_codebooks,
237
- codebook_size=codebook_size,
238
- codebook_dim=codebook_dim,
239
- quantizer_dropout=quantizer_dropout,
240
- )
241
-
242
- self.downsample_factor = downsample_factor
243
- self.downsample_dims = downsample_dims
244
-
245
- convnet_type = CausalConvNet
246
- transconvnet_type = CausalTransConvNet
247
-
248
- self.downsample = nn.Sequential(
249
- *[
250
- nn.Sequential(
251
- convnet_type(
252
- all_dims[idx],
253
- all_dims[idx + 1],
254
- kernel_size=factor,
255
- stride=factor,
256
- ),
257
- ConvNeXtBlock(dim=all_dims[idx + 1]),
258
- )
259
- for idx, factor in enumerate(downsample_factor)
260
- ]
261
- )
262
-
263
- self.upsample = nn.Sequential(
264
- *[
265
- nn.Sequential(
266
- transconvnet_type(
267
- all_dims[idx + 1],
268
- all_dims[idx],
269
- kernel_size=factor,
270
- stride=factor,
271
- ),
272
- ConvNeXtBlock(dim=all_dims[idx]),
273
- )
274
- for idx, factor in reversed(list(enumerate(downsample_factor)))
275
- ]
276
- )
277
- self.apply(self._init_weights)
278
- self.pre_module = (
279
- pre_module if pre_module is not None else nn.Identity()
280
- ) # leave for transformer, LSTM or Mamba or something else
281
- self.post_module = post_module if post_module is not None else nn.Identity()
282
- self.semantic_predictor_module = (
283
- semantic_predictor_module
284
- if semantic_predictor_module is not None
285
- else nn.Identity()
286
- )
287
-
288
- def _init_weights(self, m):
289
- if isinstance(m, (nn.Conv1d, nn.Linear)):
290
- nn.init.trunc_normal_(m.weight, std=0.02)
291
- nn.init.constant_(m.bias, 0)
292
-
293
- def forward(
294
- self, z, n_quantizers: int = None, semantic_len: torch.Tensor = None, **kwargs
295
- ):
296
- # z: (B, D, T)
297
- original_shape = z.shape
298
- if semantic_len is None:
299
- semantic_len = torch.LongTensor([z.shape[-1]])
300
- z = self.downsample(z)
301
- z = self.pre_module(z) # B, T, D
302
- (
303
- semantic_z,
304
- semantic_codes,
305
- semantic_latents,
306
- semantic_commitment_loss,
307
- semantic_codebook_loss,
308
- ) = self.semantic_quantizer(z)
309
- residual_z = z - semantic_z
310
- residual_z, codes, latents, commitment_loss, codebook_loss = self.quantizer(
311
- residual_z, n_quantizers=n_quantizers
312
- )
313
- z = semantic_z + residual_z
314
- commitment_loss = commitment_loss + semantic_commitment_loss
315
- codebook_loss = codebook_loss + semantic_codebook_loss
316
- codes = torch.cat([semantic_codes, codes], dim=1)
317
- latents = torch.cat([semantic_latents, latents], dim=1)
318
- z = self.post_module(z)
319
- z = self.upsample(z)
320
- # z: (B, D, T)
321
-
322
- # semantic distillation (disabled here since only used in training)
323
- # semantic_distill_z = self.semantic_predictor_module(semantic_z, semantic_len).mT # wav2vec target is B, T, D
324
-
325
- # Pad or crop z to match original shape
326
- diff = original_shape[-1] - z.shape[-1]
327
- right = 0
328
- left = abs(diff) - right
329
-
330
- if diff > 0:
331
- z = F.pad(z, (left, right))
332
- elif diff < 0:
333
- z = z[..., left:]
334
-
335
- results = VQResult(
336
- z=z,
337
- codes=codes,
338
- latents=latents,
339
- commitment_loss=commitment_loss,
340
- codebook_loss=codebook_loss,
341
- )
342
-
343
- return results
344
-
345
- # def encode(self, z):
346
- # z = self.downsample(z)
347
- # z = self.pre_module(z)
348
- # _, indices, _, _, _ = self.quantizer(z.mT)
349
- # indices = rearrange(indices, "g b l r -> b (g r) l")
350
- # return indices
351
- #
352
- def decode(self, indices: torch.Tensor):
353
- # indices = rearrange(indices, "b (g r) l -> g b l r", g=self.residual_fsq.groups)
354
- indices[:, 0] = torch.clamp(
355
- indices[:, 0], max=self.semantic_quantizer.codebook_size - 1
356
- )
357
- indices[:, 1:] = torch.clamp(
358
- indices[:, 1:], max=self.quantizer.codebook_size - 1
359
- )
360
-
361
- z_q_semantic = self.semantic_quantizer.from_codes(indices[:, :1])[0]
362
- z_q_residual = self.quantizer.from_codes(indices[:, 1:])[0]
363
- z_q = z_q_semantic + z_q_residual
364
- z_q = self.post_module(z_q)
365
- z_q = self.upsample(z_q)
366
- return z_q
367
-
368
- # def from_latents(self, latents: torch.Tensor):
369
- # z_q, z_p, codes = super().from_latents(latents)
370
- # z_q = self.upsample(z_q)
371
- # return z_q, z_p, codes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/text2semantic/__init__.py DELETED
@@ -1 +0,0 @@
1
-
 
 
fish_speech/models/text2semantic/errors.py DELETED
@@ -1,5 +0,0 @@
1
- class InvalidInputError(ValueError):
2
- """The request cannot be served as given: bad text, references or length.
3
-
4
- The API server answers these with 400; every other failure is a 500.
5
- """
 
 
 
 
 
 
fish_speech/models/text2semantic/fish_omni/__init__.py DELETED
@@ -1 +0,0 @@
1
-
 
 
fish_speech/models/text2semantic/fish_omni/backbone.py DELETED
@@ -1,397 +0,0 @@
1
- import re
2
- from collections import defaultdict
3
- from collections.abc import Iterable, Sequence
4
-
5
- import torch
6
- import torch.nn as nn
7
- from loguru import logger
8
- from torch import Tensor
9
- from torch.types import Device
10
-
11
- from fish_speech.models.text2semantic.fish_omni.config import FishOmniTextConfig
12
- from fish_speech.models.text2semantic.fish_omni.fp8_moe import FP8_DTYPE
13
- from fish_speech.models.text2semantic.fish_omni.layers import (
14
- FullAttention,
15
- GatedDeltaNet,
16
- LayerContext,
17
- ZeroCenteredRMSNorm,
18
- )
19
- from fish_speech.models.text2semantic.fish_omni.moe import (
20
- SparseMoeBlock,
21
- clamp_to_float16,
22
- dequantize_fp8_blocks,
23
- )
24
-
25
- CHECKPOINT_BACKBONE_PREFIX = "model.language_model."
26
- CHECKPOINT_LM_HEAD_KEY = "lm_head.weight"
27
- # Per-expert checkpoint tensors, named after the backbone prefix became "model.".
28
- EXPERT_KEY_PATTERN = re.compile(
29
- r"model\.layers\.(?P<layer>\d+)\.mlp\.experts\.(?P<expert>\d+)\."
30
- r"(?P<proj>gate_proj|up_proj|down_proj)\.(?P<kind>weight|weight_scale_inv)"
31
- )
32
- # Hugging Face's fused expert tensors (unquantized checkpoints).
33
- FUSED_EXPERT_KEY_PATTERN = re.compile(
34
- r"model\.layers\.(?P<layer>\d+)\.mlp\.experts\.(?P<proj>gate_up_proj|down_proj)"
35
- )
36
- STACKED_EXPERT_PARAMETER_PATTERN = re.compile(
37
- r"(model\.layers\.\d+\.mlp\.experts)\.(w13|w2)_(weight|scale_inv)"
38
- )
39
-
40
-
41
- class DecoderLayer(nn.Module):
42
- def __init__(
43
- self,
44
- config: FishOmniTextConfig,
45
- layer_index: int,
46
- *,
47
- fp8: bool,
48
- dtype: torch.dtype | None = None,
49
- device: Device = None,
50
- ) -> None:
51
- super().__init__()
52
- self.layer_type = config.layer_types[layer_index]
53
- if self.layer_type == "full_attention":
54
- self.self_attn = FullAttention(config, dtype=dtype, device=device)
55
- else:
56
- self.linear_attn = GatedDeltaNet(config, dtype=dtype, device=device)
57
- self.mlp = SparseMoeBlock(config, fp8=fp8, dtype=dtype, device=device)
58
- norm = dict(dtype=dtype, device=device)
59
- self.input_layernorm = ZeroCenteredRMSNorm(
60
- config.hidden_size, config.rms_norm_eps, **norm
61
- )
62
- self.post_attention_layernorm = ZeroCenteredRMSNorm(
63
- config.hidden_size, config.rms_norm_eps, **norm
64
- )
65
-
66
- def forward(self, x: Tensor, context: LayerContext) -> Tensor:
67
- """x is the residual stream, possibly wider than the compute dtype."""
68
- compute_dtype = self.input_layernorm.weight.dtype
69
- mixer = (
70
- self.self_attn if self.layer_type == "full_attention" else self.linear_attn
71
- )
72
- x = x + mixer(self.input_layernorm(x).to(compute_dtype), context)
73
- return x + self.mlp(self.post_attention_layernorm(x).to(compute_dtype))
74
-
75
-
76
- class FishOmniTextModel(nn.Module):
77
- def __init__(
78
- self,
79
- config: FishOmniTextConfig,
80
- *,
81
- fp8: bool,
82
- dtype: torch.dtype | None = None,
83
- device: Device = None,
84
- ) -> None:
85
- super().__init__()
86
- self.embed_tokens = nn.Embedding(
87
- config.vocab_size, config.hidden_size, dtype=dtype, device=device
88
- )
89
- self.layers = nn.ModuleList(
90
- DecoderLayer(config, index, fp8=fp8, dtype=dtype, device=device)
91
- for index in range(config.num_hidden_layers)
92
- )
93
- self.norm = ZeroCenteredRMSNorm(
94
- config.hidden_size, config.rms_norm_eps, dtype=dtype, device=device
95
- )
96
-
97
-
98
- class FishOmniBackbone(nn.Module):
99
- """Qwen3.5-MoE hybrid text backbone + lm_head; layers may span several devices."""
100
-
101
- def __init__(
102
- self,
103
- config: FishOmniTextConfig,
104
- *,
105
- fp8_experts: bool = False,
106
- dtype: torch.dtype = torch.bfloat16,
107
- device: Device = None,
108
- ) -> None:
109
- super().__init__()
110
- self.config = config
111
- self.dtype = dtype
112
- # The residual stream of this model exceeds the float16 range.
113
- self.residual_dtype = torch.float32 if dtype == torch.float16 else dtype
114
- self.model = FishOmniTextModel(
115
- config, fp8=fp8_experts, dtype=dtype, device=device
116
- )
117
- self.lm_head = nn.Linear(
118
- config.hidden_size,
119
- config.vocab_size,
120
- bias=False,
121
- dtype=dtype,
122
- device=device,
123
- )
124
- self.max_batch_size = 0
125
- self.max_seq_len = 0
126
- self.inverse_frequencies: dict[torch.device, Tensor] = {}
127
-
128
- def materialize(self, devices: Sequence[Device]) -> None:
129
- """Allocate a meta-device backbone over devices in contiguous layer ranges.
130
-
131
- Ranges are balanced by bytes, counting the embedding on the first device and
132
- the final norm + lm_head on the last.
133
- """
134
- devices = [torch.device(d) for d in devices]
135
- layer_bytes = [module_bytes(layer) for layer in self.model.layers]
136
- head = module_bytes(self.model.embed_tokens)
137
- tail = module_bytes(self.lm_head) + module_bytes(self.model.norm)
138
- per_device = (head + sum(layer_bytes) + tail) / len(devices)
139
- self.model.embed_tokens.to_empty(device=devices[0])
140
- self.model.norm.to_empty(device=devices[-1])
141
- self.lm_head.to_empty(device=devices[-1])
142
- placement = defaultdict(list)
143
- cumulative = head
144
- for index, (layer, size) in enumerate(zip(self.model.layers, layer_bytes)):
145
- middle = cumulative + size / 2
146
- device = devices[min(int(middle // per_device), len(devices) - 1)]
147
- layer.to_empty(device=device)
148
- placement[str(device)].append(index)
149
- cumulative += size
150
- self.inverse_frequencies.clear()
151
- logger.info(
152
- "FishOmni backbone placement: "
153
- + ", ".join(f"{d}: layers {v[0]}-{v[-1]}" for d, v in placement.items())
154
- + f"; embed on {devices[0]}, lm_head on {devices[-1]}"
155
- )
156
-
157
- def setup_caches(self, max_batch_size: int, max_seq_len: int) -> None:
158
- assert max_seq_len <= self.config.max_position_embeddings
159
- for layer in self.model.layers:
160
- device = layer.input_layernorm.weight.device
161
- if layer.layer_type == "full_attention":
162
- layer.self_attn.allocate_cache(
163
- max_batch_size, max_seq_len, self.dtype, device
164
- )
165
- else:
166
- layer.linear_attn.allocate_cache(max_batch_size, self.dtype, device)
167
- self.max_batch_size = max_batch_size
168
- self.max_seq_len = max_seq_len
169
-
170
- def embed(self, input_ids: Tensor) -> Tensor:
171
- embeddings = self.model.embed_tokens
172
- return embeddings(input_ids.to(embeddings.weight.device))
173
-
174
- def forward(
175
- self,
176
- input_ids: Tensor | None = None,
177
- *,
178
- inputs_embeds: Tensor | None = None,
179
- input_pos: Tensor | None = None,
180
- static_kv_len: int | None = None,
181
- ) -> Tensor:
182
- """Final-norm hidden states [B, T, H] of B rows at the T positions input_pos.
183
-
184
- With caches, rows at positions > 0 continue the cached sequence; without,
185
- only a whole sequence from position 0 can run. static_kv_len (CUDA-graph
186
- decode) attends that many cache rows without a host sync.
187
- """
188
- assert (input_ids is None) != (inputs_embeds is None)
189
- hidden = self.embed(input_ids) if inputs_embeds is None else inputs_embeds
190
- hidden = hidden.to(self.residual_dtype)
191
- batch, length = hidden.shape[:2]
192
- if static_kv_len is not None:
193
- assert input_pos is not None and length == 1
194
- assert 0 < static_kv_len <= self.max_seq_len
195
- return self.run_layers(
196
- hidden,
197
- input_pos,
198
- kv_len=static_kv_len,
199
- continue_state=True,
200
- use_cache=True,
201
- )
202
- if input_pos is None:
203
- input_pos = torch.arange(length, device=hidden.device)
204
- assert input_pos.shape == (length,)
205
- start, end = int(input_pos[0]), int(input_pos[-1]) + 1
206
- assert start >= 0 and end - start == length, "input_pos must be contiguous"
207
- use_cache = self.max_seq_len > 0
208
- if use_cache:
209
- assert (
210
- batch <= self.max_batch_size and end <= self.max_seq_len
211
- ), "rows exceed the caches"
212
- else:
213
- assert start == 0, "continuing a sequence needs setup_caches first"
214
- return self.run_layers(
215
- hidden, input_pos, kv_len=end, continue_state=start > 0, use_cache=use_cache
216
- )
217
-
218
- def run_layers(
219
- self,
220
- hidden: Tensor,
221
- input_pos: Tensor,
222
- *,
223
- kv_len: int,
224
- continue_state: bool,
225
- use_cache: bool,
226
- ) -> Tensor:
227
- rotary_dim = self.config.rotary_dim
228
- contexts: dict[torch.device, LayerContext] = {}
229
- for layer in self.model.layers:
230
- device = layer.input_layernorm.weight.device
231
- if device not in contexts:
232
- frequencies = self.inverse_frequencies.get(device)
233
- if frequencies is None:
234
- exponents = torch.arange(
235
- 0, rotary_dim, 2, dtype=torch.float32, device=device
236
- )
237
- frequencies = 1.0 / (
238
- self.config.rope_theta ** (exponents / rotary_dim)
239
- )
240
- self.inverse_frequencies[device] = frequencies
241
- positions = input_pos.to(device)
242
- angles = positions.float()[:, None] * frequencies[None, :]
243
- angles = torch.cat((angles, angles), dim=-1)
244
- contexts[device] = LayerContext(
245
- input_pos=positions,
246
- cos=angles.cos(),
247
- sin=angles.sin(),
248
- kv_len=kv_len,
249
- continue_state=continue_state,
250
- use_cache=use_cache,
251
- )
252
- hidden = layer(hidden.to(device), contexts[device])
253
- norm = self.model.norm
254
- return norm(hidden.to(norm.weight.device)).to(self.dtype)
255
-
256
- def compute_logits(self, hidden: Tensor) -> Tensor:
257
- return self.lm_head(hidden.to(self.lm_head.weight.device))
258
-
259
-
260
- def module_bytes(module: nn.Module) -> int:
261
- tensors = [*module.parameters(), *module.buffers()]
262
- return sum(t.numel() * t.element_size() for t in tensors)
263
-
264
-
265
- def backbone_name(checkpoint_name: str) -> str:
266
- """Map a checkpoint key onto a FishOmniBackbone parameter name."""
267
- if checkpoint_name == CHECKPOINT_LM_HEAD_KEY:
268
- return "lm_head.weight"
269
- if checkpoint_name.startswith(CHECKPOINT_BACKBONE_PREFIX):
270
- return "model." + checkpoint_name[len(CHECKPOINT_BACKBONE_PREFIX) :]
271
- if checkpoint_name.startswith("model."):
272
- return checkpoint_name
273
- raise ValueError(f"checkpoint key {checkpoint_name!r} is not a backbone weight")
274
-
275
-
276
- @torch.no_grad()
277
- def load_backbone_weights(
278
- model: FishOmniBackbone, weights: Iterable[tuple[str, Tensor]]
279
- ) -> None:
280
- """Copy checkpoint tensors into model, filling every parameter slot exactly once.
281
-
282
- Slots are (name, expert, part): stacked expert tensors are filled per expert and
283
- per gate / up / down projection, everything else whole.
284
- """
285
- targets = dict(model.named_parameters())
286
- targets.update(model.named_buffers())
287
- expected: set[tuple[str, int, str]] = set()
288
- for name in targets:
289
- match = STACKED_EXPERT_PARAMETER_PATTERN.fullmatch(name)
290
- if match is None:
291
- expected.add((name, -1, "whole"))
292
- continue
293
- experts = model.get_submodule(match.group(1))
294
- parts = ("gate", "up") if match.group(2) == "w13" else ("down",)
295
- for expert in range(experts.num_experts):
296
- expected.update((name, expert, part) for part in parts)
297
- loaded: set[tuple[str, int, str]] = set()
298
- # FP8 checkpoint experts loaded into dense experts: a weight or scale waits here
299
- # for its partner, then the pair is dequantized on the target's device.
300
- pending_fp8: dict[tuple[str, int, str], dict[str, Tensor]] = {}
301
-
302
- def fill(name: str, expert: int, part: str, target: Tensor, tensor: Tensor) -> None:
303
- slot = (name, expert, part)
304
- if slot not in expected:
305
- raise ValueError(f"no parameter slot {slot}")
306
- if slot in loaded:
307
- raise ValueError(f"parameter slot {slot} filled twice")
308
- if target.shape != tensor.shape:
309
- raise ValueError(
310
- f"{slot}: checkpoint shape {tuple(tensor.shape)} != "
311
- f"{tuple(target.shape)}"
312
- )
313
- if target.dtype == FP8_DTYPE and tensor.dtype != FP8_DTYPE:
314
- raise ValueError(f"{slot}: expected {FP8_DTYPE}, got {tensor.dtype}")
315
- if target.dtype == torch.float16 and tensor.dtype != torch.float16:
316
- tensor = clamp_to_float16(tensor, f"{name}[{expert}].{part}")
317
- target.copy_(tensor)
318
- loaded.add(slot)
319
-
320
- for checkpoint_name, tensor in weights:
321
- name = backbone_name(checkpoint_name)
322
- expert_match = EXPERT_KEY_PATTERN.fullmatch(name)
323
- fused_match = FUSED_EXPERT_KEY_PATTERN.fullmatch(name)
324
- if expert_match is not None:
325
- prefix = f"model.layers.{expert_match['layer']}.mlp.experts"
326
- experts = model.get_submodule(prefix)
327
- expert = int(expert_match["expert"])
328
- projection = expert_match["proj"]
329
- is_scale = expert_match["kind"] == "weight_scale_inv"
330
- if not is_scale and experts.fp8 and tensor.dtype != FP8_DTYPE:
331
- raise ValueError(f"{checkpoint_name}: expected an FP8 expert weight")
332
- if not experts.fp8 and (is_scale or tensor.dtype == FP8_DTYPE):
333
- key = (prefix, expert, projection)
334
- halves = pending_fp8.setdefault(key, {})
335
- if expert_match["kind"] in halves:
336
- raise ValueError(f"{checkpoint_name} appears twice")
337
- halves[expert_match["kind"]] = tensor
338
- if len(halves) < 2:
339
- continue
340
- del pending_fp8[key]
341
- target = experts.w13_weight
342
- tensor = dequantize_fp8_blocks(
343
- halves["weight"].to(target.device),
344
- halves["weight_scale_inv"].to(target.device),
345
- torch.float32,
346
- )
347
- if target.dtype == torch.float16:
348
- tensor = clamp_to_float16(tensor, checkpoint_name)
349
- tensor = tensor.to(target.dtype)
350
- is_scale = False
351
- suffix = "scale_inv" if is_scale else "weight"
352
- if projection == "down_proj":
353
- full_name = f"{prefix}.w2_{suffix}"
354
- fill(full_name, expert, "down", targets[full_name][expert], tensor)
355
- else:
356
- full_name = f"{prefix}.w13_{suffix}"
357
- rows = targets[full_name].shape[1] // 2
358
- gate = projection == "gate_proj"
359
- half = slice(0, rows) if gate else slice(rows, 2 * rows)
360
- fill(
361
- full_name,
362
- expert,
363
- "gate" if gate else "up",
364
- targets[full_name][expert, half],
365
- tensor,
366
- )
367
- elif fused_match is not None:
368
- prefix = f"model.layers.{fused_match['layer']}.mlp.experts"
369
- if fused_match["proj"] == "down_proj":
370
- full_name = f"{prefix}.w2_weight"
371
- for expert, matrix in enumerate(tensor):
372
- fill(full_name, expert, "down", targets[full_name][expert], matrix)
373
- else:
374
- full_name = f"{prefix}.w13_weight"
375
- rows = targets[full_name].shape[1] // 2
376
- for expert, matrix in enumerate(tensor):
377
- target = targets[full_name][expert]
378
- fill(full_name, expert, "gate", target[:rows], matrix[:rows])
379
- fill(full_name, expert, "up", target[rows:], matrix[rows:])
380
- else:
381
- if name not in targets:
382
- raise ValueError(
383
- f"checkpoint key {checkpoint_name!r} has no parameter {name!r}"
384
- )
385
- fill(name, -1, "whole", targets[name], tensor)
386
-
387
- if pending_fp8:
388
- raise ValueError(
389
- f"{len(pending_fp8)} FP8 experts lack a weight or scale, "
390
- f"e.g. {sorted(pending_fp8)[:4]}"
391
- )
392
- if missing := expected - loaded:
393
- raise ValueError(
394
- f"{len(missing)} backbone parameter slots were not loaded, "
395
- f"e.g. {sorted(missing)[:8]}"
396
- )
397
- logger.info(f"FishOmni backbone: loaded {len(loaded)} parameter slots")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/text2semantic/fish_omni/chunking.py DELETED
@@ -1,206 +0,0 @@
1
- """Long-text chunking for Drama 3, modeled on the hosted inference server.
2
-
3
- Text is cut into chunks of about DRAMA3_CHUNK_BYTES UTF-8 bytes with at most
4
- DRAMA3_CHUNK_MAX_SPEAKERS speakers, preferring sentence ends, then clause marks, then
5
- spaces. Each chunk is generated on its own; the first chunk and the latest one go
6
- back into the next prompt as user text plus assistant audio, which keeps the voice
7
- consistent across chunks.
8
- """
9
-
10
- import re
11
- from collections import deque
12
- from collections.abc import Iterator
13
-
14
- import torch
15
-
16
- from fish_speech.models.text2semantic.fish_omni.generate import (
17
- SPEAKER_TAG_PATTERN,
18
- UNICODE_WHITE_SPACE,
19
- )
20
-
21
- # A threshold, not a hard cap: a protected span longer than this stays whole.
22
- DRAMA3_CHUNK_BYTES = 300
23
- DRAMA3_CHUNK_MAX_SPEAKERS = 3
24
- # Frames per chunk for max_new_tokens <= 0, about 27.9 s at 2048 samples per
25
- # frame and 44.1 kHz.
26
- DRAMA3_CHUNK_MAX_NEW_TOKENS = 600
27
- # History besides the pinned first chunk.
28
- DRAMA3_HISTORY_RECENT_TURNS = 1
29
-
30
- SENTENCE_ENDS = frozenset(".!?;。!?;…\n")
31
- CLAUSE_MARKS = frozenset(",:,:、")
32
- SPACES = frozenset(" ")
33
- # Never split inside: special tokens, paired tags such as <whisper>…</whisper>, and
34
- # [bracketed] cues.
35
- PROTECTED_SPAN = re.compile(
36
- r"<\|[^|>]*\|>|<([A-Za-z][\w-]*)\b[^>]*>.*?</\1>|\[[^\[\]]*\]", re.S
37
- )
38
-
39
- Piece = tuple[str, bool] # (text, protected)
40
- HistoryTurn = tuple[str, torch.Tensor] # (chunk text, codes [num_codebooks, frames])
41
-
42
-
43
- def utf8_len(text: str) -> int:
44
- return len(text.encode("utf-8"))
45
-
46
-
47
- def _protected_pieces(text: str) -> list[Piece]:
48
- pieces, last = [], 0
49
- for match in PROTECTED_SPAN.finditer(text):
50
- if match.start() > last:
51
- pieces.append((text[last : match.start()], False))
52
- pieces.append((match.group(0), True))
53
- last = match.end()
54
- if last < len(text):
55
- pieces.append((text[last:], False))
56
- return pieces
57
-
58
-
59
- def _breaks_at(text: str, index: int, marks: frozenset[str]) -> bool:
60
- if text[index] not in marks:
61
- return False
62
- # Keep decimals such as 3.14 in one piece.
63
- return not (
64
- text[index] == "."
65
- and 0 < index < len(text) - 1
66
- and text[index - 1].isdigit()
67
- and text[index + 1].isdigit()
68
- )
69
-
70
-
71
- def _break_at_marks(pieces: list[Piece], length: int, marks: frozenset[str]):
72
- """Split free pieces longer than length bytes after each mark, keeping it."""
73
- out = []
74
- for text, protected in pieces:
75
- if protected or utf8_len(text) <= length:
76
- out.append((text, protected))
77
- continue
78
- start = 0
79
- for index in range(len(text)):
80
- if _breaks_at(text, index, marks):
81
- out.append((text[start : index + 1], False))
82
- start = index + 1
83
- if start < len(text):
84
- out.append((text[start:], False))
85
- return out
86
-
87
-
88
- def _break_by_length(pieces: list[Piece], length: int) -> list[Piece]:
89
- """Split the free pieces still longer than length on character boundaries."""
90
- out = []
91
- for text, protected in pieces:
92
- if protected or utf8_len(text) <= length:
93
- out.append((text, protected))
94
- continue
95
- current, size = "", 0
96
- for char in text:
97
- if current and size + utf8_len(char) > length:
98
- out.append((current, False))
99
- current, size = "", 0
100
- current += char
101
- size += utf8_len(char)
102
- out.append((current, False))
103
- return out
104
-
105
-
106
- def split_by_length(text: str, length: int = DRAMA3_CHUNK_BYTES) -> list[str]:
107
- """Chunks of about length bytes, packed sentence by sentence.
108
-
109
- Text is cut at every sentence end; a sentence still longer than length is cut
110
- at clause marks, then spaces, then on raw length. Adjacent pieces are then
111
- merged back up to length.
112
- """
113
- if not text.strip(UNICODE_WHITE_SPACE):
114
- return []
115
- if utf8_len(text) <= length:
116
- return [text]
117
- pieces = _break_at_marks(_protected_pieces(text), 0, SENTENCE_ENDS)
118
- for marks in (CLAUSE_MARKS, SPACES):
119
- pieces = _break_at_marks(pieces, length, marks)
120
- pieces = _break_by_length(pieces, length)
121
-
122
- chunks, current = [], ""
123
- for piece, _ in pieces:
124
- if current and utf8_len(current + piece) > length:
125
- chunks.append(current)
126
- current = ""
127
- current += piece
128
- chunks.append(current)
129
- return [
130
- chunk.strip(UNICODE_WHITE_SPACE)
131
- for chunk in chunks
132
- if chunk.strip(UNICODE_WHITE_SPACE)
133
- ]
134
-
135
-
136
- def parse_speaker_segments(text: str) -> list[tuple[str, str]]:
137
- """(speaker id, text) per tagged segment; text before the first tag is speaker 0."""
138
- parts = SPEAKER_TAG_PATTERN.split(text)
139
- segments = [("0", parts[0])] if parts[0].strip(UNICODE_WHITE_SPACE) else []
140
- for speaker, body in zip(parts[1::2], parts[2::2]):
141
- if body.strip(UNICODE_WHITE_SPACE):
142
- segments.append((speaker, body))
143
- return segments
144
-
145
-
146
- def split_drama3_text(
147
- text: str,
148
- max_bytes: int = DRAMA3_CHUNK_BYTES,
149
- max_speakers: int = DRAMA3_CHUNK_MAX_SPEAKERS,
150
- ) -> list[str]:
151
- """Generation chunks for text, in order.
152
-
153
- Untagged text is split by length. Tagged text is split into speaker segments
154
- (long ones cut further, each piece keeping its tag) and regrouped: a new chunk
155
- starts when a segment would bring in one speaker too many or exceed max_bytes,
156
- so A, B, A can share a chunk.
157
- """
158
- if SPEAKER_TAG_PATTERN.search(text) is None:
159
- return split_by_length(text, max_bytes)
160
-
161
- segments = []
162
- for speaker, body in parse_speaker_segments(text):
163
- for piece in split_by_length(body, max_bytes):
164
- segments.append((speaker, "<" + f"|speaker:{speaker}|> {piece}"))
165
-
166
- chunks, batch, speakers, size = [], [], [], 0
167
- for speaker, segment in segments:
168
- new_speaker = speaker not in speakers
169
- too_many_speakers = new_speaker and len(speakers) >= max_speakers
170
- too_long = bool(batch) and size + utf8_len(segment) > max_bytes
171
- if too_many_speakers or too_long:
172
- chunks.append("\n".join(batch))
173
- batch, speakers, size = [], [], 0
174
- new_speaker = True
175
- batch.append(segment)
176
- if new_speaker:
177
- speakers.append(speaker)
178
- size += utf8_len(segment)
179
- if batch:
180
- chunks.append("\n".join(batch))
181
- return chunks
182
-
183
-
184
- class Drama3History:
185
- """Context for the next chunk: the first chunk stays pinned, plus the latest
186
- DRAMA3_HISTORY_RECENT_TURNS chunks."""
187
-
188
- def __init__(self, recent_turns: int = DRAMA3_HISTORY_RECENT_TURNS) -> None:
189
- self.pinned: HistoryTurn | None = None
190
- self.recent: deque[HistoryTurn] = deque(maxlen=recent_turns)
191
-
192
- def push(self, text: str, codes: torch.Tensor) -> None:
193
- if self.pinned is None:
194
- self.pinned = (text, codes)
195
- else:
196
- self.recent.append((text, codes))
197
-
198
- def candidates(self) -> Iterator[list[HistoryTurn]]:
199
- """Histories to try, longest first: drop the oldest recent turns, then the
200
- pinned one, ending with no history."""
201
- pinned = [self.pinned] if self.pinned is not None else []
202
- recent = list(self.recent)
203
- for start in range(len(recent) + 1):
204
- yield pinned + recent[start:]
205
- if pinned:
206
- yield []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/text2semantic/fish_omni/config.py DELETED
@@ -1,249 +0,0 @@
1
- from dataclasses import dataclass, fields
2
- from typing import Any
3
-
4
- from fish_speech.models.text2semantic.fish_omni.fp8_moe import FP8_BLOCK_SIZE
5
-
6
- FISH_OMNI_MODEL_TYPE = "fish_omni"
7
- TEXT_MODEL_TYPES = ("fish_omni_text", "qwen3_5_moe_text")
8
- SPEECH_TEMPLATE_VERSION = "reference-bracket-v1"
9
- FP8_QUANT_METHOD = "bsgemm-moe"
10
- # Text config flags fixed by the architecture: (key, default when absent, required value).
11
- FIXED_TEXT_FLAGS = (
12
- ("rotary_interleaved", False, False),
13
- ("use_qk_norm", True, True),
14
- ("attn_output_gate", True, True),
15
- ("layernorm_zero_centered_gamma", True, True),
16
- ("shared_expert_gate", True, True),
17
- )
18
- # attention_qk_norm is deliberately not checked; see TowerAttention.
19
- FIXED_AUDIO_DECODER_FLAGS = {
20
- "decoder_type": "codebook_mtp_shared",
21
- "rotary_interleaved": True,
22
- "attention_output_gate": True,
23
- "layernorm_zero_centered_gamma": True,
24
- "use_moe": False,
25
- "mtp_use_swa": False,
26
- "attention_qkv_bias": False,
27
- "attention_o_bias": False,
28
- "dropout": 0.0,
29
- }
30
-
31
-
32
- @dataclass(kw_only=True)
33
- class FishOmniTextConfig:
34
- vocab_size: int
35
- hidden_size: int
36
- num_hidden_layers: int
37
- layer_types: list[str]
38
- num_attention_heads: int
39
- num_key_value_heads: int
40
- head_dim: int
41
- rope_theta: float
42
- partial_rotary_factor: float
43
- rms_norm_eps: float
44
- max_position_embeddings: int
45
- linear_conv_kernel_dim: int
46
- linear_key_head_dim: int
47
- linear_value_head_dim: int
48
- linear_num_key_heads: int
49
- linear_num_value_heads: int
50
- num_experts: int
51
- num_experts_per_tok: int
52
- moe_intermediate_size: int
53
- shared_expert_intermediate_size: int
54
- norm_topk_prob: bool = True
55
-
56
- def __post_init__(self) -> None:
57
- if len(self.layer_types) != self.num_hidden_layers or not set(
58
- self.layer_types
59
- ) <= {"linear_attention", "full_attention"}:
60
- raise ValueError(
61
- "layer_types must name linear_attention / full_attention for each of "
62
- f"the {self.num_hidden_layers} layers; got {self.layer_types!r}"
63
- )
64
- if self.num_attention_heads % self.num_key_value_heads:
65
- raise ValueError(
66
- "num_attention_heads must be a multiple of num_key_value_heads"
67
- )
68
- if self.linear_num_value_heads % self.linear_num_key_heads:
69
- raise ValueError(
70
- "linear_num_value_heads must be a multiple of linear_num_key_heads"
71
- )
72
- if self.num_experts_per_tok > self.num_experts:
73
- raise ValueError("num_experts_per_tok exceeds num_experts")
74
- if self.rotary_dim <= 0 or self.rotary_dim % 2:
75
- raise ValueError(
76
- f"rotary width must be positive and even; got {self.rotary_dim}"
77
- )
78
-
79
- @property
80
- def rotary_dim(self) -> int:
81
- return int(self.head_dim * self.partial_rotary_factor)
82
-
83
- @classmethod
84
- def from_dict(cls, data: dict[str, Any]) -> "FishOmniTextConfig":
85
- """Parse a fish_omni_text or qwen3_5_moe_text config."""
86
- if data.get("model_type") not in TEXT_MODEL_TYPES:
87
- raise ValueError(
88
- f"text_config.model_type={data.get('model_type')!r}; "
89
- f"expected one of {TEXT_MODEL_TYPES}"
90
- )
91
- if any(t != "moe" for t in data.get("mlp_layer_types") or ()):
92
- raise NotImplementedError("every text_config.mlp_layer_types must be moe")
93
- for key, default, required in FIXED_TEXT_FLAGS:
94
- if data.get(key, default) is not required:
95
- raise NotImplementedError(
96
- f"text_config.{key} must be {required}; got {data.get(key)!r}"
97
- )
98
- if data.get("tie_word_embeddings", False):
99
- raise NotImplementedError("the backbone keeps a separate lm_head")
100
- rope = data.get("rope_parameters") or data
101
- if rope.get("rope_type", rope.get("type", "default")) != "default":
102
- raise NotImplementedError("only the default rope_type is supported")
103
-
104
- num_layers = data["num_hidden_layers"]
105
- layer_types = data.get("layer_types") or [
106
- (
107
- "full_attention"
108
- if (i + 1) % data["full_attention_interval"] == 0
109
- else "linear_attention"
110
- )
111
- for i in range(num_layers)
112
- ]
113
- # Text-only positions are equal on every M-RoPE axis, so an mrope_section
114
- # reduces to the plain 1-D rotation.
115
- return cls(
116
- vocab_size=data["vocab_size"],
117
- hidden_size=data["hidden_size"],
118
- num_hidden_layers=num_layers,
119
- layer_types=list(layer_types),
120
- num_attention_heads=data["num_attention_heads"],
121
- num_key_value_heads=data["num_key_value_heads"],
122
- head_dim=data["head_dim"],
123
- rope_theta=rope["rope_theta"],
124
- # Older configs keep the factor at the top level; 0.25 is the HF default.
125
- partial_rotary_factor=rope.get(
126
- "partial_rotary_factor", data.get("partial_rotary_factor", 0.25)
127
- ),
128
- rms_norm_eps=data["rms_norm_eps"],
129
- max_position_embeddings=data["max_position_embeddings"],
130
- linear_conv_kernel_dim=data["linear_conv_kernel_dim"],
131
- linear_key_head_dim=data["linear_key_head_dim"],
132
- linear_value_head_dim=data["linear_value_head_dim"],
133
- linear_num_key_heads=data["linear_num_key_heads"],
134
- linear_num_value_heads=data["linear_num_value_heads"],
135
- num_experts=data["num_experts"],
136
- num_experts_per_tok=data["num_experts_per_tok"],
137
- moe_intermediate_size=data["moe_intermediate_size"],
138
- shared_expert_intermediate_size=data["shared_expert_intermediate_size"],
139
- norm_topk_prob=data.get("norm_topk_prob", True),
140
- )
141
-
142
-
143
- @dataclass(kw_only=True, frozen=True)
144
- class AudioDecoderConfig:
145
- dim: int
146
- intermediate_size: int
147
- n_head: int
148
- n_local_heads: int
149
- head_dim: int
150
- norm_eps: float
151
- rope_base: float
152
- rotary_percent: float
153
- vocab_size: int
154
- num_codebooks: int
155
- shared_enrichment_layers: int
156
- vq_backbone_codebooks: int
157
-
158
- def __post_init__(self) -> None:
159
- if (
160
- self.n_head % self.n_local_heads
161
- or not 0 < self.rotary_dim <= self.head_dim
162
- or self.rotary_dim % 2
163
- ):
164
- raise ValueError("invalid tower attention geometry")
165
- if (
166
- self.num_codebooks < 2
167
- or not 0 <= self.vq_backbone_codebooks < self.num_codebooks
168
- ):
169
- raise ValueError("invalid codebook counts")
170
- if self.shared_enrichment_layers < 1:
171
- raise ValueError("shared_enrichment_layers must be >= 1")
172
-
173
- @property
174
- def rotary_dim(self) -> int:
175
- return int(self.head_dim * self.rotary_percent)
176
-
177
- @classmethod
178
- def from_dict(cls, data: dict[str, Any]) -> "AudioDecoderConfig":
179
- for key, value in FIXED_AUDIO_DECODER_FLAGS.items():
180
- if data.get(key, value) != value:
181
- raise ValueError(
182
- f"unsupported audio_decoder_config.{key}={data[key]!r}"
183
- )
184
- if data.get("text_dim", data["dim"]) != data["dim"]:
185
- raise ValueError("audio_decoder_config.text_dim must equal dim")
186
- if data.get("n_layer") != data["shared_enrichment_layers"]:
187
- raise ValueError("n_layer must equal shared_enrichment_layers")
188
- placement = data.get("vq_feature_placement")
189
- if placement not in ("backbone_partial", "mtp_shared_pre") or (
190
- placement == "mtp_shared_pre" and data["vq_backbone_codebooks"] != 0
191
- ):
192
- raise ValueError(f"unsupported vq_feature_placement {placement!r}")
193
- return cls(**{f.name: data[f.name] for f in fields(cls)})
194
-
195
-
196
- @dataclass(kw_only=True)
197
- class FishOmniConfig:
198
- text: FishOmniTextConfig
199
- audio_decoder: AudioDecoderConfig
200
- semantic_start_token_id: int
201
- semantic_end_token_id: int
202
- eos_token_id: int
203
- fp8_experts: bool = False
204
-
205
- def __post_init__(self) -> None:
206
- start, end = self.semantic_start_token_id, self.semantic_end_token_id
207
- if not 0 <= start <= end < self.text.vocab_size:
208
- raise ValueError("the semantic band must lie inside the text vocabulary")
209
- if start <= self.eos_token_id <= end:
210
- raise ValueError("eos_token_id lies inside the semantic band")
211
- if self.audio_decoder.dim != self.text.hidden_size:
212
- raise ValueError("tower and backbone hidden sizes must match")
213
- if self.audio_decoder.vocab_size != end - start + 1:
214
- raise ValueError("tower vocabulary must match the semantic token band")
215
-
216
- @classmethod
217
- def from_dict(cls, data: dict[str, Any]) -> "FishOmniConfig":
218
- if data.get("model_type") != FISH_OMNI_MODEL_TYPE:
219
- raise ValueError(
220
- f"model_type={data.get('model_type')!r}; "
221
- f"expected {FISH_OMNI_MODEL_TYPE!r}"
222
- )
223
- if data.get("audio_encoder_config") is not None:
224
- raise NotImplementedError("audio_encoder_config is not supported")
225
- if data.get("speech_template_version") != SPEECH_TEMPLATE_VERSION:
226
- raise NotImplementedError(
227
- f"speech_template_version={data.get('speech_template_version')!r}; "
228
- f"only {SPEECH_TEMPLATE_VERSION} is supported"
229
- )
230
- quantization = data.get("quantization_config")
231
- if quantization is not None and (
232
- quantization.get("quant_method") != FP8_QUANT_METHOD
233
- or quantization.get("weight_block_size") != [FP8_BLOCK_SIZE] * 2
234
- ):
235
- raise NotImplementedError(
236
- f"only {FP8_QUANT_METHOD} quantization with weight_block_size "
237
- f"[{FP8_BLOCK_SIZE}, {FP8_BLOCK_SIZE}] is supported"
238
- )
239
- eos = data.get("eos_token_id", data["text_config"].get("eos_token_id"))
240
- if eos is None:
241
- raise ValueError("config has no eos_token_id")
242
- return cls(
243
- text=FishOmniTextConfig.from_dict(data["text_config"]),
244
- audio_decoder=AudioDecoderConfig.from_dict(data["audio_decoder_config"]),
245
- semantic_start_token_id=data["semantic_start_token_id"],
246
- semantic_end_token_id=data["semantic_end_token_id"],
247
- eos_token_id=eos,
248
- fp8_experts=quantization is not None,
249
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/text2semantic/fish_omni/fp8_moe.py DELETED
@@ -1,355 +0,0 @@
1
- from collections.abc import Iterable
2
-
3
- import torch
4
- from torch import Tensor
5
-
6
- try:
7
- import triton
8
- import triton.language as tl
9
- except ImportError: # Triton ships only with the CUDA / ROCm builds of torch.
10
- triton = None
11
-
12
- FP8_DTYPE = torch.float8_e4m3fn
13
- # Weight block edge of bsgemm-moe checkpoints; also the activation quant group width.
14
- FP8_BLOCK_SIZE = 128
15
- FP8_MAX = float(torch.finfo(FP8_DTYPE).max)
16
- FP8_MIN_SCALE = 1e-10
17
- GEMM_BLOCK_N = 128
18
- GEMM_NUM_WARPS = 4
19
- GEMM_NUM_STAGES = 3
20
-
21
-
22
- def fp8_gemm_supported(devices: Iterable[str | torch.device]) -> bool:
23
- """Every device is a CUDA SM89+ GPU (FP8 tensor cores) and Triton is available.
24
-
25
- ROCm also reports its GPUs as "cuda" with a capability, but the kernels use the
26
- e4m3fn format, which AMD GPUs lack (MI300 uses fnuz, RDNA3 has no FP8).
27
- """
28
- if triton is None or not torch.cuda.is_available() or torch.version.hip:
29
- return False
30
- devices = [torch.device(device) for device in devices]
31
- return all(
32
- device.type == "cuda" and torch.cuda.get_device_capability(device) >= (8, 9)
33
- for device in devices
34
- )
35
-
36
-
37
- if triton is not None:
38
-
39
- @triton.jit
40
- def quant_kernel(
41
- x_ptr,
42
- out_ptr,
43
- scale_ptr,
44
- stride_x,
45
- cols,
46
- fp8_max: tl.constexpr,
47
- min_scale: tl.constexpr,
48
- GROUP: tl.constexpr,
49
- ):
50
- """Dynamic FP8 quantization of one (row, channel group)."""
51
- row = tl.program_id(0).to(tl.int64)
52
- group = tl.program_id(1)
53
- offs = group * GROUP + tl.arange(0, GROUP)
54
- mask = offs < cols
55
- x = tl.load(x_ptr + row * stride_x + offs, mask=mask, other=0.0)
56
- x = x.to(tl.float32)
57
- scale = tl.maximum(tl.max(tl.abs(x)), min_scale) / fp8_max
58
- q = tl.clamp(x / scale, -fp8_max, fp8_max)
59
- tl.store(out_ptr + row * cols + offs, q.to(out_ptr.dtype.element_ty), mask=mask)
60
- tl.store(scale_ptr + row * tl.num_programs(1) + group, scale)
61
-
62
- @triton.jit
63
- def silu_mul_quant_kernel(
64
- x_ptr,
65
- out_ptr,
66
- scale_ptr,
67
- stride_x,
68
- half,
69
- fp8_max: tl.constexpr,
70
- min_scale: tl.constexpr,
71
- GROUP: tl.constexpr,
72
- ):
73
- """silu(gate) * up of x = [gate | up], then FP8 quantization per group."""
74
- row = tl.program_id(0).to(tl.int64)
75
- group = tl.program_id(1)
76
- offs = group * GROUP + tl.arange(0, GROUP)
77
- mask = offs < half
78
- gate = tl.load(x_ptr + row * stride_x + offs, mask=mask, other=0.0)
79
- up = tl.load(x_ptr + row * stride_x + half + offs, mask=mask, other=0.0)
80
- gate, up = gate.to(tl.float32), up.to(tl.float32)
81
- y = gate * tl.sigmoid(gate) * up
82
- scale = tl.maximum(tl.max(tl.abs(y)), min_scale) / fp8_max
83
- q = tl.clamp(y / scale, -fp8_max, fp8_max)
84
- tl.store(out_ptr + row * half + offs, q.to(out_ptr.dtype.element_ty), mask=mask)
85
- tl.store(scale_ptr + row * tl.num_programs(1) + group, scale)
86
-
87
- @triton.jit
88
- def fused_moe_kernel(
89
- a_ptr,
90
- b_ptr,
91
- c_ptr,
92
- a_scale_ptr,
93
- b_scale_ptr,
94
- topk_weights_ptr,
95
- sorted_ids_ptr,
96
- expert_ids_ptr,
97
- num_post_padded_ptr,
98
- N,
99
- K,
100
- num_valid,
101
- stride_am,
102
- stride_be,
103
- stride_bn,
104
- stride_bk,
105
- stride_cm,
106
- stride_asm,
107
- stride_bse,
108
- stride_bsn,
109
- stride_bsk,
110
- GROUP: tl.constexpr,
111
- BLOCK_M: tl.constexpr,
112
- BLOCK_N: tl.constexpr,
113
- BLOCK_K: tl.constexpr,
114
- GROUP_M: tl.constexpr,
115
- TOP_K: tl.constexpr,
116
- MUL_ROUTED_WEIGHT: tl.constexpr,
117
- ):
118
- """C[pair] = A[pair // TOP_K] @ B[expert(pair)]^T for one output tile.
119
-
120
- Rows are (token, slot) pairs sorted by expert, BLOCK_M rows per expert;
121
- padding rows hold num_valid and are masked out.
122
- """
123
- pid = tl.program_id(0)
124
- EM = tl.load(num_post_padded_ptr)
125
- num_pid_m = tl.cdiv(EM, BLOCK_M)
126
- num_pid_n = tl.cdiv(N, BLOCK_N)
127
- # The grid is sized for the worst case; blocks past the real layout exit.
128
- if pid >= num_pid_m * num_pid_n:
129
- return
130
- num_pid_in_group = GROUP_M * num_pid_n
131
- group_id = pid // num_pid_in_group
132
- first_pid_m = group_id * GROUP_M
133
- group_size_m = min(num_pid_m - first_pid_m, GROUP_M)
134
- pid_m = first_pid_m + ((pid % num_pid_in_group) % group_size_m)
135
- pid_n = (pid % num_pid_in_group) // group_size_m
136
-
137
- offs_row = pid_m * BLOCK_M + tl.arange(0, BLOCK_M).to(tl.int64)
138
- pair = tl.load(sorted_ids_ptr + offs_row).to(tl.int64)
139
- pair_mask = pair < num_valid
140
- expert = tl.load(expert_ids_ptr + pid_m).to(tl.int64)
141
-
142
- offs_n = (pid_n * BLOCK_N + tl.arange(0, BLOCK_N).to(tl.int64)) % N
143
- offs_k = tl.arange(0, BLOCK_K)
144
- token = pair // TOP_K
145
- a_ptrs = a_ptr + token[:, None] * stride_am + offs_k[None, :]
146
- b_ptrs = (
147
- b_ptr
148
- + expert * stride_be
149
- + offs_k[:, None] * stride_bk
150
- + offs_n[None, :] * stride_bn
151
- )
152
- a_scale_ptrs = a_scale_ptr + token * stride_asm
153
- b_scale_ptrs = (
154
- b_scale_ptr + expert * stride_bse + (offs_n // GROUP) * stride_bsn
155
- )
156
-
157
- acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
158
- for k in range(0, tl.cdiv(K, BLOCK_K)):
159
- k_mask = offs_k < K - k * BLOCK_K
160
- a = tl.load(a_ptrs, mask=pair_mask[:, None] & k_mask[None, :], other=0.0)
161
- b = tl.load(b_ptrs, mask=k_mask[:, None], other=0.0)
162
- k_group = (k * BLOCK_K) // GROUP
163
- a_scale = tl.load(a_scale_ptrs + k_group, mask=pair_mask, other=0.0)
164
- b_scale = tl.load(b_scale_ptrs + k_group * stride_bsk)
165
- acc += tl.dot(a, b) * a_scale[:, None] * b_scale[None, :]
166
- a_ptrs += BLOCK_K
167
- b_ptrs += BLOCK_K * stride_bk
168
-
169
- if MUL_ROUTED_WEIGHT:
170
- w = tl.load(topk_weights_ptr + pair, mask=pair_mask, other=0.0)
171
- acc = acc * w.to(tl.float32)[:, None]
172
- offs_cn = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
173
- c_ptrs = c_ptr + pair[:, None] * stride_cm + offs_cn[None, :]
174
- tl.store(
175
- c_ptrs,
176
- acc.to(c_ptr.dtype.element_ty),
177
- mask=pair_mask[:, None] & (offs_cn[None, :] < N),
178
- )
179
-
180
-
181
- def quantize_groups(
182
- kernel: "triton.JITFunction", x: Tensor, cols: int
183
- ) -> tuple[Tensor, Tensor]:
184
- """Run a per-token-group quant kernel: FP8 [M, cols] + fp32 scales [M, groups]."""
185
- rows = x.shape[0]
186
- groups = triton.cdiv(cols, FP8_BLOCK_SIZE)
187
- quantized = torch.empty((rows, cols), dtype=FP8_DTYPE, device=x.device)
188
- scales = torch.empty((rows, groups), dtype=torch.float32, device=x.device)
189
- kernel[(rows, groups)](
190
- x,
191
- quantized,
192
- scales,
193
- x.stride(0),
194
- cols,
195
- fp8_max=FP8_MAX,
196
- min_scale=FP8_MIN_SCALE,
197
- GROUP=FP8_BLOCK_SIZE,
198
- num_warps=1,
199
- )
200
- return quantized, scales
201
-
202
-
203
- def moe_align(
204
- topk_ids: Tensor, num_experts: int, block_m: int
205
- ) -> tuple[Tensor, Tensor, Tensor]:
206
- """Sort (token, slot) pairs by expert, padding each expert to a multiple of block_m.
207
-
208
- Stays on the device (no host sync). Returns sorted_ids [max_rows] (padding rows
209
- hold the pair count), expert_ids [max_rows / block_m] and num_post_padded [1].
210
- """
211
- flat = topk_ids.reshape(-1).to(torch.int64)
212
- pairs = flat.numel()
213
- device = flat.device
214
- # At most min(pairs, experts) experts are hit, each padded by < block_m rows.
215
- hit_bound = min(pairs, num_experts)
216
- max_rows = triton.cdiv(pairs + hit_bound * (block_m - 1), block_m) * block_m
217
-
218
- # bincount syncs with the host on CUDA; scatter_add does not.
219
- counts = torch.zeros(num_experts, dtype=torch.int64, device=device)
220
- counts.scatter_add_(0, flat, torch.ones_like(flat))
221
- padded = (counts + block_m - 1) // block_m * block_m
222
- ends = padded.cumsum(0)
223
- starts = ends - padded
224
- count_starts = counts.cumsum(0) - counts
225
- order = torch.argsort(flat, stable=True)
226
- sorted_experts = flat[order]
227
- rank = torch.arange(pairs, device=device) - count_starts[sorted_experts]
228
- sorted_ids = torch.full((max_rows,), pairs, dtype=torch.int32, device=device)
229
- sorted_ids[starts[sorted_experts] + rank] = order.to(torch.int32)
230
-
231
- block_starts = torch.arange(0, max_rows, block_m, device=device)
232
- expert_ids = torch.searchsorted(ends, block_starts, right=True)
233
- expert_ids = expert_ids.clamp_(max=num_experts - 1).to(torch.int32)
234
- return sorted_ids, expert_ids, ends[-1:].to(torch.int32)
235
-
236
-
237
- def fused_moe_gemm(
238
- a: Tensor,
239
- a_scale: Tensor,
240
- b: Tensor,
241
- b_scale: Tensor,
242
- out: Tensor,
243
- topk_weights: Tensor | None,
244
- sorted_ids: Tensor,
245
- expert_ids: Tensor,
246
- num_post_padded: Tensor,
247
- *,
248
- top_k: int,
249
- block_m: int,
250
- group_m: int,
251
- ) -> None:
252
- _, n, k = b.shape
253
- grid = (triton.cdiv(sorted_ids.numel(), block_m) * triton.cdiv(n, GEMM_BLOCK_N),)
254
- fused_moe_kernel[grid](
255
- a,
256
- b,
257
- out,
258
- a_scale,
259
- b_scale,
260
- # Any valid pointer; the kernel only reads it with MUL_ROUTED_WEIGHT.
261
- a_scale if topk_weights is None else topk_weights,
262
- sorted_ids,
263
- expert_ids,
264
- num_post_padded,
265
- n,
266
- k,
267
- a.shape[0] * top_k,
268
- a.stride(0),
269
- b.stride(0),
270
- b.stride(1),
271
- b.stride(2),
272
- out.stride(0),
273
- a_scale.stride(0),
274
- b_scale.stride(0),
275
- b_scale.stride(1),
276
- b_scale.stride(2),
277
- GROUP=FP8_BLOCK_SIZE,
278
- BLOCK_M=block_m,
279
- BLOCK_N=GEMM_BLOCK_N,
280
- BLOCK_K=FP8_BLOCK_SIZE,
281
- GROUP_M=group_m,
282
- TOP_K=top_k,
283
- MUL_ROUTED_WEIGHT=topk_weights is not None,
284
- num_warps=GEMM_NUM_WARPS,
285
- num_stages=GEMM_NUM_STAGES,
286
- )
287
-
288
-
289
- def fp8_moe_forward(
290
- x: Tensor,
291
- topk_ids: Tensor,
292
- topk_weights: Tensor,
293
- w13_weight: Tensor,
294
- w13_scale_inv: Tensor,
295
- w2_weight: Tensor,
296
- w2_scale_inv: Tensor,
297
- ) -> Tensor:
298
- """Route x [N, H] through block-FP8 experts (W8A8) -> weighted expert sum [N, H].
299
-
300
- w13_weight [E, 2I, H] holds gate rows then up rows, w2_weight [E, H, I]; both
301
- float8_e4m3fn with fp32 [E, rows / 128, cols / 128] block scales.
302
- """
303
- tokens, hidden = x.shape
304
- if tokens == 0:
305
- return torch.zeros_like(x)
306
- num_experts, gate_up_rows, _ = w13_weight.shape
307
- top_k = topk_ids.shape[-1]
308
- pairs = tokens * top_k
309
- # Decode routes a handful of rows per expert: smaller M tiles waste less.
310
- if pairs <= 64:
311
- block_m, group_m = 16, 1
312
- elif pairs <= 1024:
313
- block_m, group_m = 32, 1
314
- else:
315
- block_m, group_m = 64, 8
316
-
317
- # Triton launches on the current device; layers may live on another GPU.
318
- with torch.cuda.device(x.device):
319
- sorted_ids, expert_ids, num_post_padded = moe_align(
320
- topk_ids, num_experts, block_m
321
- )
322
- layout = dict(top_k=top_k, block_m=block_m, group_m=group_m)
323
- x_q, x_scale = quantize_groups(quant_kernel, x.contiguous(), hidden)
324
- gate_up = torch.empty((pairs, gate_up_rows), dtype=x.dtype, device=x.device)
325
- fused_moe_gemm(
326
- x_q,
327
- x_scale,
328
- w13_weight,
329
- w13_scale_inv,
330
- gate_up,
331
- None,
332
- sorted_ids,
333
- expert_ids,
334
- num_post_padded,
335
- **layout,
336
- )
337
- h_q, h_scale = quantize_groups(
338
- silu_mul_quant_kernel, gate_up, gate_up_rows // 2
339
- )
340
- down = torch.empty((pairs, hidden), dtype=torch.float32, device=x.device)
341
- fused_moe_gemm(
342
- h_q,
343
- h_scale,
344
- w2_weight,
345
- w2_scale_inv,
346
- down,
347
- topk_weights.reshape(-1).contiguous(),
348
- sorted_ids,
349
- expert_ids,
350
- num_post_padded,
351
- top_k=1,
352
- block_m=block_m,
353
- group_m=group_m,
354
- )
355
- return down.view(tokens, top_k, hidden).sum(dim=1).to(x.dtype)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/text2semantic/fish_omni/generate.py DELETED
@@ -1,505 +0,0 @@
1
- import os
2
- import re
3
- import time
4
- from collections.abc import Iterator, Sequence
5
- from typing import Literal
6
-
7
- import torch
8
- from loguru import logger
9
- from tokenizers import Tokenizer
10
- from tqdm import tqdm
11
-
12
- from fish_speech.models.text2semantic.errors import InvalidInputError
13
- from fish_speech.models.text2semantic.fish_omni.config import FishOmniConfig
14
- from fish_speech.models.text2semantic.fish_omni.model import FishOmniModel
15
- from fish_speech.models.text2semantic.fish_omni.tower import (
16
- RAS_WINDOW_FRAMES,
17
- TowerAttention,
18
- )
19
- from fish_speech.models.text2semantic.sampling import (
20
- DEFAULT_TEMPERATURE,
21
- DEFAULT_TOP_K,
22
- DEFAULT_TOP_P,
23
- )
24
-
25
- # Special-token strings are assembled with "<" + "|..." so the literal two
26
- # characters never appear side by side in this source file (tooling mangles them).
27
- IM_START = "<" + "|im_start|>"
28
- IM_END = "<" + "|im_end|>"
29
- VOICE_TAG = "<" + "|voice|>"
30
- SPEAKER_0 = "<" + "|speaker:0|>"
31
-
32
- DRAMA3_TTS_INSTRUCTION = "convert the provided text to speech"
33
- DRAMA3_REFERENCE_INSTRUCTION = (
34
- "convert the provided text to speech reference to the following:"
35
- )
36
- DRAMA3_MESSAGE_END = IM_END + "\n"
37
- DRAMA3_GENERATION_START = IM_START + "assistant\n" + VOICE_TAG
38
- # Only the prompt builder writes these; other special tokens in caller text are content.
39
- DRAMA3_STRUCTURAL_TOKENS = (
40
- IM_START,
41
- IM_END,
42
- VOICE_TAG,
43
- "<" + "|text|>",
44
- "<" + "|interleave|>",
45
- "<" + "|semantic:",
46
- )
47
- SPEAKER_TAG_PATTERN = re.compile("<[|]" + r"speaker:([0-9]+)" + "[|]>")
48
- # The tokenizer has one token per speaker, speaker:0 to speaker:15.
49
- MAX_SPEAKER_ID = 15
50
- # The Unicode White_Space property; str.strip() without arguments also strips
51
- # U+001C..U+001F, which would change the empty check and the speaker-tag rule.
52
- UNICODE_WHITE_SPACE = "".join(
53
- map(
54
- chr,
55
- (*range(0x9, 0xE), 0x20, 0x85, 0xA0, 0x1680, *range(0x2000, 0x200B))
56
- + (0x2028, 0x2029, 0x202F, 0x205F, 0x3000),
57
- )
58
- )
59
- DRAMA3_MAX_PROMPT_ROWS = 8192
60
- DRAMA3_DEFAULT_MAX_NEW_TOKENS = 2048
61
- DRAMA3_PREFILL_CHUNK_SIZE = 128
62
- DRAMA3_MAX_DEFAULT_DEVICES = 4
63
- # CUDA-graph decode: caches hold the largest default request, with one graph per
64
- # attended cache length; a step replays the smallest bucket covering its position.
65
- DRAMA3_GRAPH_CAPACITY = DRAMA3_MAX_PROMPT_ROWS + DRAMA3_DEFAULT_MAX_NEW_TOKENS
66
- DRAMA3_GRAPH_KV_BUCKETS = (1024, 2048, 4096, DRAMA3_GRAPH_CAPACITY)
67
-
68
- FinishReason = Literal["eos", "length"]
69
-
70
-
71
- class Drama3PromptTooLongError(InvalidInputError):
72
- """The prompt has more than DRAMA3_MAX_PROMPT_ROWS rows."""
73
-
74
-
75
- def validate_drama3_text(text: str, location: str) -> None:
76
- """Non-empty, no structural tokens, and only canonical speaker tags 0..15."""
77
- if not text.strip(UNICODE_WHITE_SPACE):
78
- raise InvalidInputError(f"{location}: text must not be empty")
79
- for token in DRAMA3_STRUCTURAL_TOKENS:
80
- if token in text:
81
- raise InvalidInputError(
82
- f"{location}: {token} is written by the prompt builder"
83
- )
84
- for marker in re.finditer("<[|]" + "speaker", text):
85
- tag = SPEAKER_TAG_PATTERN.match(text, marker.start())
86
- digits = tag.group(1) if tag else ""
87
- if not digits or str(int(digits)) != digits or int(digits) > MAX_SPEAKER_ID:
88
- raise InvalidInputError(
89
- f"{location}: speaker tags must be {SPEAKER_0} to "
90
- + "<" + f"|speaker:{MAX_SPEAKER_ID}|>; got {text!r}"
91
- )
92
-
93
-
94
- def with_speaker_tag(text: str) -> str:
95
- """Prefix with speaker tag 0 unless a tag already leads after white space."""
96
- if SPEAKER_TAG_PATTERN.match(text.lstrip(UNICODE_WHITE_SPACE)) is None:
97
- return SPEAKER_0 + " " + text
98
- return text
99
-
100
-
101
- def build_drama3_prompt(
102
- text: str,
103
- tokenizer: Tokenizer,
104
- config: FishOmniConfig,
105
- prompt_texts: Sequence[str] = (),
106
- prompt_tokens: Sequence[torch.Tensor] = (),
107
- history: Sequence[tuple[str, torch.Tensor]] = (),
108
- ) -> torch.Tensor:
109
- """CPU int64 rows [rows, 1 + num_codebooks] ending in the open assistant voice turn.
110
-
111
- Text rows are [token, 0, ..., 0]; audio rows are [c0 + semantic_start, c0..cN-1].
112
- Every literal is tokenized on its own: tokenizing the joined string changes ids
113
- at the seams. history holds earlier (text, codes) chunks, written as completed
114
- user and assistant voice turns before the current one.
115
- """
116
- num_codebooks = config.audio_decoder.num_codebooks
117
- codebook_size = config.audio_decoder.vocab_size
118
- for token, expected in (
119
- ("<" + "|semantic:0|>", config.semantic_start_token_id),
120
- ("<" + f"|semantic:{codebook_size - 1}|>", config.semantic_end_token_id),
121
- (IM_END, config.eos_token_id),
122
- ):
123
- if tokenizer.token_to_id(token) != expected:
124
- raise ValueError(f"tokenizer/config mismatch for {token}")
125
- if len(prompt_texts) != len(prompt_tokens):
126
- raise InvalidInputError("every reference needs both a transcript and codes")
127
- validate_drama3_text(text, "text")
128
-
129
- def check_codes(codes: torch.Tensor, location: str) -> None:
130
- if (
131
- codes.dtype.is_floating_point
132
- or codes.dtype == torch.bool
133
- or codes.ndim != 2
134
- or codes.shape[0] != num_codebooks
135
- or codes.shape[1] == 0
136
- ):
137
- raise InvalidInputError(
138
- f"{location}: codes must be integer [{num_codebooks}, frames >= 1]; "
139
- f"got {codes.dtype} {tuple(codes.shape)}"
140
- )
141
- if codes.min() < 0 or codes.max() >= codebook_size:
142
- raise InvalidInputError(
143
- f"{location}: codes must be in [0, {codebook_size})"
144
- )
145
-
146
- segments: list[str | torch.Tensor] = [
147
- IM_START + "system\n",
148
- DRAMA3_REFERENCE_INSTRUCTION if prompt_tokens else DRAMA3_TTS_INSTRUCTION,
149
- ]
150
-
151
- for index, (transcript, codes) in enumerate(zip(prompt_texts, prompt_tokens), 1):
152
- location = f"reference {index}"
153
- validate_drama3_text(transcript, location)
154
- check_codes(codes, location)
155
- # The tag must follow "Text: " directly; leading white space moves behind it.
156
- tagged = with_speaker_tag(transcript)
157
- body = tagged.lstrip(UNICODE_WHITE_SPACE)
158
- tag = SPEAKER_TAG_PATTERN.match(body)
159
- leading = tagged[: len(tagged) - len(body)]
160
- transcript = tag.group(0) + leading + body[tag.end() :]
161
- segments += [f"\n\n[Reference {index}]\nText: {transcript}\nSpeech: ", codes]
162
- segments.append(DRAMA3_MESSAGE_END)
163
- for index, (turn_text, codes) in enumerate(history, 1):
164
- location = f"history {index}"
165
- validate_drama3_text(turn_text, location)
166
- check_codes(codes, location)
167
- segments += [
168
- IM_START + "user\n",
169
- with_speaker_tag(turn_text),
170
- DRAMA3_MESSAGE_END,
171
- DRAMA3_GENERATION_START,
172
- codes,
173
- DRAMA3_MESSAGE_END,
174
- ]
175
- segments += [
176
- IM_START + "user\n",
177
- with_speaker_tag(text),
178
- DRAMA3_MESSAGE_END,
179
- DRAMA3_GENERATION_START,
180
- ]
181
-
182
- rows = []
183
- for segment in segments:
184
- if isinstance(segment, str):
185
- ids = torch.tensor(tokenizer.encode(segment).ids, dtype=torch.long)
186
- text_rows = torch.zeros((len(ids), 1 + num_codebooks), dtype=torch.long)
187
- text_rows[:, 0] = ids
188
- rows.append(text_rows)
189
- else:
190
- codes = segment.to(device="cpu", dtype=torch.long)
191
- main = codes[:1] + config.semantic_start_token_id
192
- rows.append(torch.cat((main, codes)).T)
193
- rows = torch.cat(rows)
194
- if len(rows) > DRAMA3_MAX_PROMPT_ROWS:
195
- raise Drama3PromptTooLongError(
196
- f"prompt has {len(rows)} rows; at most {DRAMA3_MAX_PROMPT_ROWS}"
197
- )
198
- return rows
199
-
200
-
201
- @torch.inference_mode()
202
- def generate_drama3(
203
- model: FishOmniModel,
204
- prompt: torch.Tensor,
205
- *,
206
- max_new_tokens: int,
207
- temperature: float = DEFAULT_TEMPERATURE,
208
- top_k: int = DEFAULT_TOP_K,
209
- top_p: float = DEFAULT_TOP_P,
210
- prefill_chunk_size: int = DRAMA3_PREFILL_CHUNK_SIZE,
211
- ) -> tuple[torch.Tensor, FinishReason]:
212
- """Sample frames after the prompt rows; returns CPU [frames, 1 + N] without EOS.
213
-
214
- The tower cache of prompt row p is filled with frame p + 1 as its label; the
215
- last prompt row has none yet, so the first decode step writes that slot.
216
- """
217
- # The sampler keeps the top-ranked token whatever top_k is, so top_k < 1 would
218
- # silently turn into greedy decoding.
219
- if top_k < 1:
220
- raise InvalidInputError(f"top_k must be at least 1; got {top_k}")
221
- backbone, tower = model.backbone, model.audio_decoder
222
- prompt_rows = len(prompt)
223
- capacity = prompt_rows + max_new_tokens
224
- if max_new_tokens < 1 or capacity > model.config.text.max_position_embeddings:
225
- raise ValueError(
226
- f"max_new_tokens={max_new_tokens} does not fit after {prompt_rows} "
227
- "prompt rows"
228
- )
229
- graphs = Drama3DecodeGraphs.for_model(model, capacity)
230
- if graphs is None:
231
- ensure_drama3_caches(model, capacity)
232
- frames = torch.zeros(
233
- (capacity, prompt.shape[1]), dtype=torch.long, device=tower.device
234
- )
235
- frames[:prompt_rows] = prompt.to(tower.device)
236
- positions = torch.arange(capacity, device=tower.device)
237
-
238
- started = time.perf_counter()
239
- for start in range(0, prompt_rows, prefill_chunk_size):
240
- end = min(start + prefill_chunk_size, prompt_rows)
241
- chunk = frames[start:end]
242
- embeddings = tower.embed_backbone(chunk, backbone.embed(chunk[:, 0]))
243
- hidden = backbone(
244
- inputs_embeds=embeddings[None], input_pos=positions[start:end]
245
- )
246
- hidden = hidden[0].to(tower.device)
247
- paired_end = min(end, prompt_rows - 1)
248
- if paired_end > start:
249
- tower.prefill(
250
- hidden[: paired_end - start],
251
- positions[start:paired_end],
252
- frames[start:paired_end],
253
- frames[start + 1 : paired_end + 1],
254
- )
255
- logger.info(
256
- f"Drama 3 prefill: {prompt_rows} rows in {time.perf_counter() - started:.2f}s"
257
- )
258
-
259
- hidden = hidden[-1:]
260
- produced, finish_reason = 0, "length"
261
- if graphs is not None:
262
- graphs.start(
263
- hidden, prompt_rows - 1, frames[:prompt_rows], temperature, top_k, top_p
264
- )
265
- for step in tqdm(range(max_new_tokens)):
266
- position = prompt_rows - 1 + step
267
- if graphs is not None:
268
- sampled, main = graphs.step(position)
269
- else:
270
- logits = backbone.compute_logits(hidden).to(tower.device)
271
- sampled = tower.decode_step(
272
- logits,
273
- hidden,
274
- positions[position : position + 1],
275
- frames[position : position + 1],
276
- frames[: position + 1, 0],
277
- temperature=temperature,
278
- top_k=top_k,
279
- top_p=top_p,
280
- )
281
- main = sampled[0, 0].item()
282
- if main == model.config.eos_token_id:
283
- finish_reason = "eos"
284
- break
285
- frames[position + 1] = sampled[0]
286
- produced += 1
287
- if graphs is None and step + 1 < max_new_tokens:
288
- embeddings = tower.embed_backbone(sampled, backbone.embed(sampled[:, 0]))
289
- hidden = backbone(
290
- inputs_embeds=embeddings[None],
291
- input_pos=positions[position + 1 : position + 2],
292
- )[0].to(tower.device)
293
- return frames[prompt_rows : prompt_rows + produced].cpu(), finish_reason
294
-
295
-
296
- def ensure_drama3_caches(model: FishOmniModel, capacity: int) -> None:
297
- """Grow the backbone and tower caches to capacity rows, reused across requests.
298
-
299
- Stale rows past a request's positions are masked out.
300
- """
301
- if model.cache_capacity >= capacity:
302
- return
303
- if model.decode_graphs is not None:
304
- # The graphs captured the old cache buffers.
305
- model.decode_graphs = None
306
- torch.cuda.empty_cache()
307
- model.backbone.setup_caches(max_batch_size=1, max_seq_len=capacity)
308
- model.audio_decoder.setup_caches(capacity)
309
- model.cache_capacity = capacity
310
-
311
-
312
- class Drama3DecodeGraphs:
313
- """CUDA graphs of one decode step: lm_head -> tower (sample a frame) -> backbone.
314
-
315
- Eager decode is bound by kernel launches (thousands of small kernels per step).
316
- Captured once for a model on a single CUDA device; FISH_DRAMA3_CUDA_GRAPH=0 disables.
317
- """
318
-
319
- def __init__(self, model: FishOmniModel) -> None:
320
- backbone, tower = model.backbone, model.audio_decoder
321
- self.model = model
322
- device = tower.device
323
- width = 1 + model.config.audio_decoder.num_codebooks
324
- hidden_size = model.config.text.hidden_size
325
- self.hidden = torch.zeros((1, hidden_size), dtype=backbone.dtype, device=device)
326
- self.next_hidden = torch.zeros_like(self.hidden)
327
- self.position = torch.zeros(1, dtype=torch.long, device=device)
328
- self.frame = torch.zeros((1, width), dtype=torch.long, device=device)
329
- self.sampled = torch.zeros_like(self.frame)
330
- self.history = torch.full(
331
- (RAS_WINDOW_FRAMES,), -1, dtype=torch.long, device=device
332
- )
333
- self.temperature = torch.zeros((), dtype=torch.float32, device=device)
334
- self.top_k = torch.zeros((), dtype=torch.long, device=device)
335
- self.top_p = torch.zeros((), dtype=torch.float32, device=device)
336
- self.finite = torch.ones((), dtype=torch.bool, device=device)
337
- self.graphs: dict[int, torch.cuda.CUDAGraph] = {}
338
- self.pool = torch.cuda.graph_pool_handle()
339
- capacity = self.graph_capacity(model)
340
- self.buckets = tuple(
341
- sorted({min(b, capacity) for b in DRAMA3_GRAPH_KV_BUCKETS})
342
- )
343
-
344
- @staticmethod
345
- def graph_capacity(model: FishOmniModel) -> int:
346
- return min(DRAMA3_GRAPH_CAPACITY, model.config.text.max_position_embeddings)
347
-
348
- @classmethod
349
- def for_model(
350
- cls, model: FishOmniModel, capacity: int
351
- ) -> "Drama3DecodeGraphs | None":
352
- """The model's graphs (captured on first use), or None to decode eagerly."""
353
- if os.environ.get("FISH_DRAMA3_CUDA_GRAPH", "1") == "0":
354
- return None
355
- if capacity > cls.graph_capacity(model):
356
- return None
357
- devices = {p.device for p in model.backbone.parameters()}
358
- devices.add(model.audio_decoder.device)
359
- if len(devices) != 1 or next(iter(devices)).type != "cuda":
360
- return None
361
- if model.decode_graphs is None:
362
- # Captured before the first request's prefill, which resets the caches.
363
- ensure_drama3_caches(model, cls.graph_capacity(model))
364
- graphs = cls(model)
365
- started = time.perf_counter()
366
- with torch.cuda.device(model.audio_decoder.device):
367
- for kv_len in graphs.buckets:
368
- graphs.capture(kv_len)
369
- logger.info(
370
- f"Drama 3 decode CUDA graphs: kv buckets {graphs.buckets} captured in "
371
- f"{time.perf_counter() - started:.1f}s"
372
- )
373
- model.decode_graphs = graphs
374
- return model.decode_graphs
375
-
376
- def tower_attentions(self) -> Iterator[TowerAttention]:
377
- tower = self.model.audio_decoder
378
- for layer in (*tower.shared_layers, *tower.residual_layers):
379
- yield layer.transformer_layer.attention
380
-
381
- def run(self, kv_len: int) -> None:
382
- backbone, tower = self.model.backbone, self.model.audio_decoder
383
- logits = backbone.compute_logits(self.hidden)
384
- self.finite.copy_(torch.isfinite(logits).all())
385
- sampled = tower.decode_step(
386
- logits,
387
- self.hidden,
388
- self.position,
389
- self.frame,
390
- self.history,
391
- temperature=self.temperature,
392
- top_k=self.top_k,
393
- top_p=self.top_p,
394
- check_finite=False,
395
- )
396
- self.sampled.copy_(sampled)
397
- embeddings = tower.embed_backbone(sampled, backbone.embed(sampled[:, 0]))
398
- hidden = backbone(
399
- inputs_embeds=embeddings[None],
400
- input_pos=self.position + 1,
401
- static_kv_len=kv_len,
402
- )
403
- self.next_hidden.copy_(hidden[0])
404
-
405
- def capture(self, kv_len: int) -> None:
406
- for attention in self.tower_attentions():
407
- attention.static_kv_len = kv_len
408
- try:
409
- stream = torch.cuda.Stream()
410
- stream.wait_stream(torch.cuda.current_stream())
411
- with torch.cuda.stream(stream):
412
- for _ in range(2):
413
- self.run(kv_len)
414
- torch.cuda.current_stream().wait_stream(stream)
415
- graph = torch.cuda.CUDAGraph()
416
- with torch.cuda.graph(graph, pool=self.pool):
417
- self.run(kv_len)
418
- self.graphs[kv_len] = graph
419
- finally:
420
- for attention in self.tower_attentions():
421
- attention.static_kv_len = None
422
-
423
- def start(
424
- self,
425
- hidden: torch.Tensor,
426
- position: int,
427
- prompt_frames: torch.Tensor,
428
- temperature: float,
429
- top_k: int,
430
- top_p: float,
431
- ) -> None:
432
- """Load the state after prefill: last hidden row, its position and frame."""
433
- self.hidden.copy_(hidden)
434
- self.position.fill_(position)
435
- self.frame.copy_(prompt_frames[-1:])
436
- window = prompt_frames[-RAS_WINDOW_FRAMES:, 0]
437
- self.history.fill_(-1)
438
- self.history[RAS_WINDOW_FRAMES - len(window) :].copy_(window)
439
- self.temperature.fill_(temperature)
440
- self.top_k.fill_(top_k)
441
- self.top_p.fill_(top_p)
442
-
443
- def step(self, position: int) -> tuple[torch.Tensor, int]:
444
- """Sample the frame after position and run the backbone on it.
445
-
446
- Returns the frame [1, 1 + N] on the device and its main token.
447
- """
448
- kv_len = next(b for b in self.buckets if b >= position + 2)
449
- self.graphs[kv_len].replay()
450
- sampled = self.sampled.clone()
451
- status = torch.stack((sampled[0, 0], self.finite.long()))
452
- # Inputs of the next step, queued before the host waits on status.
453
- self.hidden.copy_(self.next_hidden)
454
- self.frame.copy_(sampled)
455
- self.history.copy_(torch.cat((self.history[1:], sampled[0, :1])))
456
- self.position.add_(1)
457
- main, finite = status.tolist()
458
- if not finite:
459
- raise FloatingPointError(
460
- "non-finite logits; refusing to sample invalid audio"
461
- )
462
- return sampled, main
463
-
464
-
465
- def resolve_drama3_devices(device: str) -> tuple[list[str], str]:
466
- """Split "cuda:0,cuda:1,..." into backbone devices and the tower (+ codec) device.
467
-
468
- The last device holds the tower; a single device holds everything. Plain "cuda"
469
- takes the first min(4, visible) GPUs.
470
- """
471
- specs = [spec.strip() for spec in device.split(",") if spec.strip()]
472
- if not specs:
473
- raise ValueError("no device given")
474
- if len(specs) == 1 and specs[0] == "cuda":
475
- count = min(torch.cuda.device_count(), DRAMA3_MAX_DEFAULT_DEVICES)
476
- if count == 0:
477
- raise RuntimeError("Drama 3 inference needs CUDA, but no GPU is visible")
478
- specs = [f"cuda:{index}" for index in range(count)]
479
- for spec in specs:
480
- parsed = torch.device(spec)
481
- if (
482
- parsed.type != "cuda"
483
- or parsed.index is None
484
- or parsed.index >= torch.cuda.device_count()
485
- ):
486
- raise ValueError(f"Drama 3 needs available cuda:N devices; got {spec!r}")
487
- if len(set(specs)) != len(specs):
488
- raise ValueError(f"duplicate devices in {device!r}")
489
- if len(specs) == 1:
490
- return specs, specs[0]
491
- return specs[:-1], specs[-1]
492
-
493
-
494
- def resolve_drama3_dtype(precision: torch.dtype, devices: list[str]) -> torch.dtype:
495
- """bfloat16 needs SM80+; on older GPUs (V100) Drama 3 runs in float16 instead."""
496
- if precision not in (torch.float16, torch.bfloat16):
497
- raise ValueError(f"Drama 3 runs in float16 or bfloat16; got {precision}")
498
- if precision == torch.bfloat16 and any(
499
- torch.cuda.get_device_capability(device) < (8, 0) for device in devices
500
- ):
501
- logger.warning(
502
- "bfloat16 needs SM80+; running Drama 3 in float16 with an fp32 residual stream"
503
- )
504
- return torch.float16
505
- return precision
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/text2semantic/fish_omni/layers.py DELETED
@@ -1,410 +0,0 @@
1
- from dataclasses import dataclass
2
-
3
- import torch
4
- import torch.nn as nn
5
- import torch.nn.functional as F
6
- from torch import Tensor
7
- from torch.types import Device
8
-
9
- from fish_speech.models.text2semantic.fish_omni.config import FishOmniTextConfig
10
-
11
- GDN_CHUNK_SIZE = 64
12
-
13
-
14
- class ZeroCenteredRMSNorm(nn.Module):
15
- """RMSNorm scaling by 1 + weight, computed in fp32."""
16
-
17
- def __init__(
18
- self,
19
- dim: int,
20
- eps: float,
21
- *,
22
- dtype: torch.dtype | None = None,
23
- device: Device = None,
24
- ) -> None:
25
- super().__init__()
26
- self.eps = eps
27
- self.weight = nn.Parameter(torch.zeros(dim, dtype=dtype, device=device))
28
-
29
- def forward(self, x: Tensor) -> Tensor:
30
- out = x.float()
31
- out = out * torch.rsqrt(out.pow(2).mean(-1, keepdim=True) + self.eps)
32
- return (out * (1.0 + self.weight.float())).type_as(x)
33
-
34
-
35
- class GatedRMSNorm(nn.Module):
36
- """Gated DeltaNet output norm: weight * rmsnorm(x) * silu(gate)."""
37
-
38
- def __init__(
39
- self,
40
- dim: int,
41
- eps: float,
42
- *,
43
- dtype: torch.dtype | None = None,
44
- device: Device = None,
45
- ) -> None:
46
- super().__init__()
47
- self.eps = eps
48
- self.weight = nn.Parameter(torch.ones(dim, dtype=dtype, device=device))
49
-
50
- def forward(self, x: Tensor, gate: Tensor) -> Tensor:
51
- dtype = x.dtype
52
- out = x.float()
53
- out = out * torch.rsqrt(out.pow(2).mean(-1, keepdim=True) + self.eps)
54
- out = self.weight * out.to(dtype)
55
- return (out * F.silu(gate.float())).to(dtype)
56
-
57
-
58
- def apply_neox_rope(x: Tensor, cos: Tensor, sin: Tensor) -> Tensor:
59
- """Rotate the first rotary_dim channels of x [B, T, heads, head_dim] (half-split)."""
60
- rotary_dim = cos.shape[-1]
61
- rotary, passthrough = x[..., :rotary_dim].float(), x[..., rotary_dim:]
62
- first, second = rotary.chunk(2, dim=-1)
63
- rotated = torch.cat((-second, first), dim=-1)
64
- cos, sin = cos[None, :, None, :], sin[None, :, None, :]
65
- out = rotary * cos + rotated * sin
66
- return torch.cat((out.to(x.dtype), passthrough), dim=-1)
67
-
68
-
69
- def grouped_attention(
70
- query: Tensor,
71
- key: Tensor,
72
- value: Tensor,
73
- mask: Tensor | None,
74
- *,
75
- is_causal: bool = False,
76
- scale: float | None = None,
77
- ) -> Tensor:
78
- """GQA SDPA: query [B, H, T, D], key / value [B, KVH, S, D], mask [T, S]."""
79
- batch, heads, length, head_dim = query.shape
80
- kv_heads = key.shape[1]
81
- groups = heads // kv_heads
82
- if groups > 1 and length == 1:
83
- # Decode: fold each KV head's query heads into rows, so the cache is read
84
- # once; the [1, S] mask broadcasts over them.
85
- folded = query.reshape(batch, kv_heads, groups, head_dim)
86
- out = F.scaled_dot_product_attention(
87
- folded, key, value, attn_mask=mask, scale=scale
88
- )
89
- return out.reshape(batch, heads, 1, head_dim)
90
- if groups > 1:
91
- key = key.repeat_interleave(groups, dim=1)
92
- value = value.repeat_interleave(groups, dim=1)
93
- return F.scaled_dot_product_attention(
94
- query, key, value, attn_mask=mask, is_causal=is_causal, scale=scale
95
- )
96
-
97
-
98
- @dataclass(kw_only=True)
99
- class LayerContext:
100
- """Per-forward inputs shared by the layers of one device."""
101
-
102
- input_pos: Tensor # [T] contiguous positions of the rows
103
- cos: Tensor
104
- sin: Tensor
105
- kv_len: int # attended cache prefix (last position + 1)
106
- continue_state: bool # the rows continue a cached sequence
107
- use_cache: bool
108
-
109
-
110
- class FullAttention(nn.Module):
111
- """Gated softmax attention: GQA, q/k norm, partial RoPE, sigmoid output gate."""
112
-
113
- def __init__(
114
- self,
115
- config: FishOmniTextConfig,
116
- *,
117
- dtype: torch.dtype | None = None,
118
- device: Device = None,
119
- ) -> None:
120
- super().__init__()
121
- self.num_heads = config.num_attention_heads
122
- self.num_kv_heads = config.num_key_value_heads
123
- self.head_dim = config.head_dim
124
- hidden = config.hidden_size
125
- linear = dict(bias=False, dtype=dtype, device=device)
126
- # Output rows: each head's [query, gate] pair.
127
- self.q_proj = nn.Linear(hidden, self.num_heads * self.head_dim * 2, **linear)
128
- self.k_proj = nn.Linear(hidden, self.num_kv_heads * self.head_dim, **linear)
129
- self.v_proj = nn.Linear(hidden, self.num_kv_heads * self.head_dim, **linear)
130
- self.o_proj = nn.Linear(self.num_heads * self.head_dim, hidden, **linear)
131
- norm = dict(dtype=dtype, device=device)
132
- self.q_norm = ZeroCenteredRMSNorm(self.head_dim, config.rms_norm_eps, **norm)
133
- self.k_norm = ZeroCenteredRMSNorm(self.head_dim, config.rms_norm_eps, **norm)
134
- self.k_cache: Tensor | None = None
135
- self.v_cache: Tensor | None = None
136
-
137
- def allocate_cache(
138
- self, batch_size: int, max_seq_len: int, dtype: torch.dtype, device: Device
139
- ) -> None:
140
- shape = (batch_size, self.num_kv_heads, max_seq_len, self.head_dim)
141
- self.k_cache = torch.zeros(shape, dtype=dtype, device=device)
142
- self.v_cache = torch.zeros(shape, dtype=dtype, device=device)
143
-
144
- def forward(self, x: Tensor, context: LayerContext) -> Tensor:
145
- batch, length, _ = x.shape
146
- query, gate = (
147
- self.q_proj(x)
148
- .view(batch, length, self.num_heads, 2 * self.head_dim)
149
- .chunk(2, dim=-1)
150
- )
151
- gate = gate.reshape(batch, length, self.num_heads * self.head_dim)
152
- query = self.q_norm(query)
153
- key = self.k_proj(x).view(batch, length, self.num_kv_heads, self.head_dim)
154
- key = self.k_norm(key)
155
- value = self.v_proj(x).view(batch, length, self.num_kv_heads, self.head_dim)
156
- query = apply_neox_rope(query, context.cos, context.sin).transpose(1, 2)
157
- key = apply_neox_rope(key, context.cos, context.sin).transpose(1, 2)
158
- value = value.transpose(1, 2)
159
-
160
- if context.use_cache:
161
- assert self.k_cache is not None and self.v_cache is not None
162
- self.k_cache[:batch].index_copy_(2, context.input_pos, key)
163
- self.v_cache[:batch].index_copy_(2, context.input_pos, value)
164
- key = self.k_cache[:batch, :, : context.kv_len]
165
- value = self.v_cache[:batch, :, : context.kv_len]
166
- key_positions = torch.arange(context.kv_len, device=x.device)
167
- mask = key_positions[None, :] <= context.input_pos[:, None]
168
- is_causal = False
169
- else:
170
- mask = None
171
- is_causal = length > 1
172
-
173
- attended = grouped_attention(
174
- query, key, value, mask, is_causal=is_causal, scale=self.head_dim**-0.5
175
- )
176
- attended = attended.transpose(1, 2).reshape(
177
- batch, length, self.num_heads * self.head_dim
178
- )
179
- return self.o_proj(attended * torch.sigmoid(gate))
180
-
181
-
182
- def l2norm(x: Tensor, eps: float = 1e-6) -> Tensor:
183
- return x * torch.rsqrt((x * x).sum(dim=-1, keepdim=True) + eps)
184
-
185
-
186
- def chunk_gated_delta_rule(
187
- query: Tensor,
188
- key: Tensor,
189
- value: Tensor,
190
- decay: Tensor,
191
- beta: Tensor,
192
- initial_state: Tensor | None = None,
193
- chunk_size: int = GDN_CHUNK_SIZE,
194
- ) -> tuple[Tensor, Tensor]:
195
- """Gated delta rule over whole chunks, in fp32.
196
-
197
- query / key [B, T, H, dk] (not yet l2-normalized), value [B, T, H, dv], decay
198
- (log space, <= 0) and beta [B, T, H], initial_state [B, H, dk, dv]. Returns the
199
- output [B, T, H, dv] in the query dtype and the final fp32 state.
200
- """
201
- output_dtype = query.dtype
202
- batch, length, heads, key_dim = key.shape
203
- value_dim = value.shape[-1]
204
- query, key, value, beta, decay = (
205
- t.transpose(1, 2).to(torch.float32).contiguous()
206
- for t in (query, key, value, beta, decay)
207
- )
208
- query = l2norm(query) * key_dim**-0.5
209
- key = l2norm(key)
210
-
211
- pad = (chunk_size - length % chunk_size) % chunk_size
212
- query, key, value = (F.pad(t, (0, 0, 0, pad)) for t in (query, key, value))
213
- beta, decay = (F.pad(t, (0, pad)) for t in (beta, decay))
214
- chunk_count = (length + pad) // chunk_size
215
-
216
- value_beta = value * beta.unsqueeze(-1)
217
- key_beta = key * beta.unsqueeze(-1)
218
- query, key, key_beta, value_beta = (
219
- t.reshape(batch, heads, chunk_count, chunk_size, t.shape[-1])
220
- for t in (query, key, key_beta, value_beta)
221
- )
222
- decay = decay.reshape(batch, heads, chunk_count, chunk_size)
223
- upper = torch.ones(
224
- chunk_size, chunk_size, dtype=torch.bool, device=query.device
225
- ).triu(1)
226
- cumulative = decay.cumsum(dim=-1)
227
- pairwise = (
228
- (cumulative.unsqueeze(-1) - cumulative.unsqueeze(-2))
229
- .masked_fill(upper, float("-inf"))
230
- .exp()
231
- )
232
-
233
- system = (key_beta @ key.transpose(-1, -2)) * pairwise
234
- intra = (query @ key.transpose(-1, -2)) * pairwise
235
- decayed_key_beta = key_beta * cumulative.exp().unsqueeze(-1)
236
- new_values = torch.linalg.solve_triangular(
237
- system, value_beta, upper=False, unitriangular=True
238
- )
239
- key_cumulative_decay = torch.linalg.solve_triangular(
240
- system, decayed_key_beta, upper=False, unitriangular=True
241
- )
242
-
243
- if initial_state is None:
244
- state = query.new_zeros(batch, heads, key_dim, value_dim)
245
- else:
246
- state = initial_state.to(torch.float32)
247
- output = torch.empty_like(new_values)
248
- query = query * cumulative.exp().unsqueeze(-1)
249
- key = key * (cumulative[..., -1:] - cumulative).exp().unsqueeze(-1)
250
- chunk_decay = cumulative[..., -1].exp()[..., None, None]
251
- for index in range(chunk_count):
252
- corrected = new_values[:, :, index] - key_cumulative_decay[:, :, index] @ state
253
- output[:, :, index] = (
254
- query[:, :, index] @ state + intra[:, :, index] @ corrected
255
- )
256
- state = (
257
- state * chunk_decay[:, :, index]
258
- + key[:, :, index].transpose(-1, -2) @ corrected
259
- )
260
-
261
- output = output.reshape(batch, heads, -1, value_dim)[:, :, :length]
262
- return output.transpose(1, 2).contiguous().to(output_dtype), state
263
-
264
-
265
- def recurrent_gated_delta_rule(
266
- query: Tensor,
267
- key: Tensor,
268
- value: Tensor,
269
- decay: Tensor,
270
- beta: Tensor,
271
- initial_state: Tensor | None = None,
272
- ) -> tuple[Tensor, Tensor]:
273
- """Token-by-token gated delta rule; same contract as chunk_gated_delta_rule."""
274
- output_dtype = query.dtype
275
- batch, length, heads, key_dim = key.shape
276
- value_dim = value.shape[-1]
277
- query, key, value, beta, decay = (
278
- t.transpose(1, 2).to(torch.float32).contiguous()
279
- for t in (query, key, value, beta, decay)
280
- )
281
- query = l2norm(query) * key_dim**-0.5
282
- key = l2norm(key)
283
- if initial_state is None:
284
- state = value.new_zeros(batch, heads, key_dim, value_dim)
285
- else:
286
- state = initial_state.to(torch.float32)
287
- output = torch.empty_like(value)
288
- for index in range(length):
289
- state = state * decay[:, :, index].exp()[..., None, None]
290
- k_t = key[:, :, index]
291
- remembered = (state * k_t.unsqueeze(-1)).sum(dim=-2)
292
- delta = (value[:, :, index] - remembered) * beta[:, :, index].unsqueeze(-1)
293
- state = state + k_t.unsqueeze(-1) * delta.unsqueeze(-2)
294
- output[:, :, index] = (state * query[:, :, index].unsqueeze(-1)).sum(dim=-2)
295
- return output.transpose(1, 2).contiguous().to(output_dtype), state
296
-
297
-
298
- class GatedDeltaNet(nn.Module):
299
- def __init__(
300
- self,
301
- config: FishOmniTextConfig,
302
- *,
303
- dtype: torch.dtype | None = None,
304
- device: Device = None,
305
- ) -> None:
306
- super().__init__()
307
- hidden = config.hidden_size
308
- self.num_k_heads = config.linear_num_key_heads
309
- self.num_v_heads = config.linear_num_value_heads
310
- self.head_k_dim = config.linear_key_head_dim
311
- self.head_v_dim = config.linear_value_head_dim
312
- self.key_dim = self.num_k_heads * self.head_k_dim
313
- self.value_dim = self.num_v_heads * self.head_v_dim
314
- self.conv_dim = 2 * self.key_dim + self.value_dim
315
- self.conv_kernel_size = config.linear_conv_kernel_dim
316
- linear = dict(bias=False, dtype=dtype, device=device)
317
- # Output rows: query, key, value.
318
- self.in_proj_qkv = nn.Linear(hidden, self.conv_dim, **linear)
319
- self.in_proj_z = nn.Linear(hidden, self.value_dim, **linear)
320
- self.in_proj_b = nn.Linear(hidden, self.num_v_heads, **linear)
321
- self.in_proj_a = nn.Linear(hidden, self.num_v_heads, **linear)
322
- self.conv1d = nn.Conv1d(
323
- self.conv_dim,
324
- self.conv_dim,
325
- kernel_size=self.conv_kernel_size,
326
- groups=self.conv_dim,
327
- bias=False,
328
- dtype=dtype,
329
- device=device,
330
- )
331
- self.dt_bias = nn.Parameter(
332
- torch.ones(self.num_v_heads, dtype=dtype, device=device)
333
- )
334
- self.A_log = nn.Parameter(
335
- torch.zeros(self.num_v_heads, dtype=dtype, device=device)
336
- )
337
- self.norm = GatedRMSNorm(
338
- self.head_v_dim, config.rms_norm_eps, dtype=dtype, device=device
339
- )
340
- self.out_proj = nn.Linear(self.value_dim, hidden, **linear)
341
- self.conv_state: Tensor | None = None
342
- self.recurrent_state: Tensor | None = None
343
-
344
- def allocate_cache(
345
- self, batch_size: int, dtype: torch.dtype, device: Device
346
- ) -> None:
347
- self.conv_state = torch.zeros(
348
- batch_size,
349
- self.conv_dim,
350
- self.conv_kernel_size - 1,
351
- dtype=dtype,
352
- device=device,
353
- )
354
- self.recurrent_state = torch.zeros(
355
- batch_size,
356
- self.num_v_heads,
357
- self.head_k_dim,
358
- self.head_v_dim,
359
- dtype=torch.float32,
360
- device=device,
361
- )
362
-
363
- def forward(self, x: Tensor, context: LayerContext) -> Tensor:
364
- batch, length, _ = x.shape
365
- mixed = self.in_proj_qkv(x).transpose(1, 2) # [B, C, T]
366
- history = self.conv_kernel_size - 1
367
- if context.continue_state:
368
- assert self.conv_state is not None
369
- previous = self.conv_state[:batch].to(mixed.dtype)
370
- else:
371
- previous = mixed.new_zeros(batch, self.conv_dim, history)
372
- padded = torch.cat((previous, mixed), dim=-1)
373
- if context.use_cache:
374
- assert self.conv_state is not None
375
- self.conv_state[:batch].copy_(padded[..., -history:])
376
- mixed = F.silu(F.conv1d(padded, self.conv1d.weight, groups=self.conv_dim))
377
- query, key, value = mixed.transpose(1, 2).split(
378
- [self.key_dim, self.key_dim, self.value_dim], dim=-1
379
- )
380
- query = query.reshape(batch, length, self.num_k_heads, self.head_k_dim)
381
- key = key.reshape(batch, length, self.num_k_heads, self.head_k_dim)
382
- value = value.reshape(batch, length, self.num_v_heads, self.head_v_dim)
383
- z = self.in_proj_z(x).reshape(batch, length, self.num_v_heads, self.head_v_dim)
384
- beta = torch.sigmoid(self.in_proj_b(x).float())
385
- decay = -self.A_log.float().exp() * F.softplus(
386
- self.in_proj_a(x).float() + self.dt_bias.float()
387
- )
388
- groups = self.num_v_heads // self.num_k_heads
389
- if groups > 1:
390
- query = query.repeat_interleave(groups, dim=2)
391
- key = key.repeat_interleave(groups, dim=2)
392
-
393
- initial_state = None
394
- if context.continue_state:
395
- assert self.recurrent_state is not None
396
- initial_state = self.recurrent_state[:batch]
397
- rule = (
398
- recurrent_gated_delta_rule
399
- if context.continue_state and length == 1
400
- else chunk_gated_delta_rule
401
- )
402
- core, final_state = rule(query, key, value, decay, beta, initial_state)
403
- if context.use_cache:
404
- assert self.recurrent_state is not None
405
- self.recurrent_state[:batch].copy_(final_state)
406
-
407
- core = self.norm(
408
- core.reshape(-1, self.head_v_dim), z.reshape(-1, self.head_v_dim)
409
- ).reshape(batch, length, self.value_dim)
410
- return self.out_proj(core)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/text2semantic/fish_omni/model.py DELETED
@@ -1,147 +0,0 @@
1
- import json
2
- import os
3
- from collections.abc import Iterator, Sequence
4
- from pathlib import Path
5
- from typing import TYPE_CHECKING
6
-
7
- import torch
8
- import torch.nn as nn
9
- from loguru import logger
10
- from safetensors import safe_open
11
- from tokenizers import Tokenizer
12
- from torch import Tensor
13
- from torch.types import Device
14
-
15
- from fish_speech.models.text2semantic.fish_omni.backbone import (
16
- CHECKPOINT_BACKBONE_PREFIX,
17
- CHECKPOINT_LM_HEAD_KEY,
18
- FishOmniBackbone,
19
- load_backbone_weights,
20
- )
21
- from fish_speech.models.text2semantic.fish_omni.config import (
22
- FISH_OMNI_MODEL_TYPE,
23
- FP8_QUANT_METHOD,
24
- FishOmniConfig,
25
- )
26
- from fish_speech.models.text2semantic.fish_omni.fp8_moe import fp8_gemm_supported
27
- from fish_speech.models.text2semantic.fish_omni.tower import (
28
- FishOmniAudioDecoder,
29
- load_audio_decoder_weights,
30
- )
31
-
32
- if TYPE_CHECKING:
33
- from fish_speech.models.text2semantic.fish_omni.generate import Drama3DecodeGraphs
34
-
35
- AUDIO_DECODER_PREFIXES = (
36
- "audio_decoder.",
37
- "audio_decoder_mtp_shared.",
38
- "audio_decoder_mtp.",
39
- )
40
-
41
-
42
- class FishOmniModel(nn.Module):
43
- """Drama 3 TTS model: backbone (over one or more devices) + codebook tower + tokenizer."""
44
-
45
- def __init__(
46
- self,
47
- config: FishOmniConfig,
48
- backbone: FishOmniBackbone,
49
- audio_decoder: FishOmniAudioDecoder,
50
- tokenizer: Tokenizer,
51
- ) -> None:
52
- super().__init__()
53
- self.config = config
54
- self.backbone = backbone
55
- self.audio_decoder = audio_decoder
56
- self.tokenizer = tokenizer
57
- # Decode state reused across requests; managed by fish_omni.generate.
58
- self.cache_capacity = 0
59
- self.decode_graphs: "Drama3DecodeGraphs | None" = None
60
-
61
- @property
62
- def dtype(self) -> torch.dtype:
63
- return self.backbone.dtype
64
-
65
- @property
66
- def device(self) -> torch.device:
67
- """Where frames are sampled and codes come out (the tower's device)."""
68
- return self.audio_decoder.device
69
-
70
- @classmethod
71
- def from_pretrained(
72
- cls,
73
- checkpoint_dir: str | Path,
74
- *,
75
- devices: Sequence[Device],
76
- tower_device: Device,
77
- dtype: torch.dtype,
78
- ) -> "FishOmniModel":
79
- """Load backbone (spread over devices), tower and tokenizer.
80
-
81
- FP8 checkpoint experts stay FP8 when every backbone device is SM89+ and are
82
- dequantized to dtype otherwise; env FISH_DRAMA3_FP8_EXPERTS=0/1 forces either way.
83
- """
84
- checkpoint_dir = Path(checkpoint_dir)
85
- with open(checkpoint_dir / "config.json", encoding="utf-8") as f:
86
- config = FishOmniConfig.from_dict(json.load(f))
87
- fp8_available = config.fp8_experts and fp8_gemm_supported(devices)
88
- forced = os.environ.get("FISH_DRAMA3_FP8_EXPERTS", "").strip()
89
- fp8_experts = forced == "1" if forced in ("0", "1") else fp8_available
90
- if fp8_experts and not fp8_available:
91
- raise ValueError(
92
- f"FP8 experts need a {FP8_QUANT_METHOD} checkpoint and SM89+ CUDA "
93
- f"devices with Triton; got {list(devices)}"
94
- )
95
- logger.info(
96
- "FishOmni experts: "
97
- + ("block-FP8 W8A8" if fp8_experts else f"dequantized to {dtype}")
98
- )
99
-
100
- with torch.device("meta"):
101
- backbone = FishOmniBackbone(
102
- config.text, fp8_experts=fp8_experts, dtype=dtype
103
- )
104
- audio_decoder = FishOmniAudioDecoder(config, dtype=dtype)
105
- backbone.materialize(devices)
106
- load_backbone_weights(
107
- backbone,
108
- iter_checkpoint_tensors(
109
- checkpoint_dir, skip_prefixes=AUDIO_DECODER_PREFIXES
110
- ),
111
- )
112
- audio_decoder.to_empty(device=tower_device)
113
- load_audio_decoder_weights(
114
- audio_decoder,
115
- iter_checkpoint_tensors(
116
- checkpoint_dir,
117
- skip_prefixes=(CHECKPOINT_BACKBONE_PREFIX, CHECKPOINT_LM_HEAD_KEY),
118
- ),
119
- )
120
- tokenizer = Tokenizer.from_file(str(checkpoint_dir / "tokenizer.json"))
121
- return cls(config, backbone, audio_decoder, tokenizer).eval()
122
-
123
-
124
- def iter_checkpoint_tensors(
125
- checkpoint_dir: Path, *, skip_prefixes: tuple[str, ...]
126
- ) -> Iterator[tuple[str, Tensor]]:
127
- """(name, CPU tensor) of every safetensors tensor not under skip_prefixes."""
128
- index_path = checkpoint_dir / "model.safetensors.index.json"
129
- if index_path.exists():
130
- with open(index_path, encoding="utf-8") as f:
131
- weight_map = json.load(f)["weight_map"]
132
- shards = sorted({checkpoint_dir / name for name in weight_map.values()})
133
- else:
134
- shards = [checkpoint_dir / "model.safetensors"]
135
- for shard in shards:
136
- with safe_open(str(shard), framework="pt", device="cpu") as f:
137
- for key in f.keys():
138
- if not key.startswith(skip_prefixes):
139
- yield key, f.get_tensor(key)
140
-
141
-
142
- def is_fish_omni_checkpoint(path: str | Path) -> bool:
143
- config_path = Path(path) / "config.json"
144
- if not config_path.is_file():
145
- return False
146
- with open(config_path, encoding="utf-8") as f:
147
- return json.load(f).get("model_type") == FISH_OMNI_MODEL_TYPE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/text2semantic/fish_omni/moe.py DELETED
@@ -1,223 +0,0 @@
1
- import torch
2
- import torch.nn as nn
3
- import torch.nn.functional as F
4
- from loguru import logger
5
- from torch import Tensor
6
- from torch.types import Device
7
-
8
- from fish_speech.models.text2semantic.fish_omni.config import FishOmniTextConfig
9
- from fish_speech.models.text2semantic.fish_omni.fp8_moe import (
10
- FP8_BLOCK_SIZE,
11
- FP8_DTYPE,
12
- fp8_moe_forward,
13
- )
14
-
15
- # Up to this many (token, expert) pairs run every expert in one batched matmul
16
- # (decode); larger batches loop over the experts they hit (prefill).
17
- GATHERED_EXPERT_MAX_ROWS = 64
18
- FLOAT16_MAX = torch.finfo(torch.float16).max
19
-
20
-
21
- def dequantize_fp8_blocks(
22
- weight: Tensor, scale_inv: Tensor, dtype: torch.dtype
23
- ) -> Tensor:
24
- """Block-FP8 [..., out, in] with fp32 scales [..., out/128, in/128] -> dtype."""
25
- *lead, rows, cols = weight.shape
26
- row_blocks, col_blocks = scale_inv.shape[-2:]
27
- blocks = weight.view(*lead, row_blocks, FP8_BLOCK_SIZE, col_blocks, FP8_BLOCK_SIZE)
28
- scaled = blocks.to(torch.float32) * scale_inv[..., :, None, :, None]
29
- return scaled.view(*lead, rows, cols).to(dtype)
30
-
31
-
32
- def clamp_to_float16(tensor: Tensor, name: str) -> Tensor:
33
- """Saturate the values float16 cannot hold, logging how many there were."""
34
- overflow = int((tensor.abs() > FLOAT16_MAX).sum())
35
- if overflow:
36
- logger.warning(f"{name}: {overflow} values exceed the float16 range; clamped")
37
- tensor = tensor.clamp(-FLOAT16_MAX, FLOAT16_MAX)
38
- return tensor
39
-
40
-
41
- def select_fp8(weight: Tensor, index: Tensor | int) -> Tensor:
42
- """weight[index] for a float8 tensor; advanced indexing has no float8 kernel."""
43
- return weight.view(torch.uint8)[index].view(weight.dtype)
44
-
45
-
46
- class DenseMLP(nn.Module):
47
- def __init__(
48
- self,
49
- hidden: int,
50
- intermediate: int,
51
- *,
52
- dtype: torch.dtype | None = None,
53
- device: Device = None,
54
- ) -> None:
55
- super().__init__()
56
- linear = dict(bias=False, dtype=dtype, device=device)
57
- self.gate_proj = nn.Linear(hidden, intermediate, **linear)
58
- self.up_proj = nn.Linear(hidden, intermediate, **linear)
59
- self.down_proj = nn.Linear(intermediate, hidden, **linear)
60
-
61
- def forward(self, x: Tensor) -> Tensor:
62
- return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
63
-
64
-
65
- class MoEExperts(nn.Module):
66
- """Routed experts stacked per layer: w13 [E, 2I, H] (gate then up), w2 [E, H, I].
67
-
68
- With fp8 the weights are float8_e4m3fn buffers with fp32 block scales; on CUDA
69
- they run on the W8A8 kernel, elsewhere they are dequantized per forward.
70
- """
71
-
72
- def __init__(
73
- self,
74
- num_experts: int,
75
- hidden: int,
76
- intermediate: int,
77
- *,
78
- fp8: bool,
79
- dtype: torch.dtype | None = None,
80
- device: Device = None,
81
- ) -> None:
82
- super().__init__()
83
- self.num_experts = num_experts
84
- self.hidden = hidden
85
- self.intermediate = intermediate
86
- self.fp8 = fp8
87
- w13_shape = (num_experts, 2 * intermediate, hidden)
88
- w2_shape = (num_experts, hidden, intermediate)
89
- if not fp8:
90
- self.w13_weight = nn.Parameter(
91
- torch.empty(w13_shape, dtype=dtype, device=device)
92
- )
93
- self.w2_weight = nn.Parameter(
94
- torch.empty(w2_shape, dtype=dtype, device=device)
95
- )
96
- return
97
- if hidden % FP8_BLOCK_SIZE or intermediate % FP8_BLOCK_SIZE:
98
- raise ValueError(
99
- f"block-FP8 experts need hidden and intermediate sizes divisible by "
100
- f"{FP8_BLOCK_SIZE}; got {hidden} and {intermediate}"
101
- )
102
- for name, (experts, rows, cols) in (("w13", w13_shape), ("w2", w2_shape)):
103
- self.register_buffer(
104
- f"{name}_weight",
105
- torch.empty((experts, rows, cols), dtype=FP8_DTYPE, device=device),
106
- )
107
- self.register_buffer(
108
- f"{name}_scale_inv",
109
- torch.empty(
110
- (experts, rows // FP8_BLOCK_SIZE, cols // FP8_BLOCK_SIZE),
111
- dtype=torch.float32,
112
- device=device,
113
- ),
114
- )
115
-
116
- def expert_weights(
117
- self, index: Tensor | int, dtype: torch.dtype
118
- ) -> tuple[Tensor, Tensor]:
119
- """Gate-up and down weights of the experts index selects, in dtype."""
120
- if not self.fp8:
121
- return self.w13_weight[index].to(dtype), self.w2_weight[index].to(dtype)
122
- return (
123
- dequantize_fp8_blocks(
124
- select_fp8(self.w13_weight, index), self.w13_scale_inv[index], dtype
125
- ),
126
- dequantize_fp8_blocks(
127
- select_fp8(self.w2_weight, index), self.w2_scale_inv[index], dtype
128
- ),
129
- )
130
-
131
- def forward(self, x: Tensor, topk_ids: Tensor, topk_weights: Tensor) -> Tensor:
132
- """x [N, H], topk_ids / topk_weights [N, k] -> weighted expert sum [N, H]."""
133
- if self.fp8 and x.is_cuda:
134
- return fp8_moe_forward(
135
- x,
136
- topk_ids,
137
- topk_weights,
138
- self.w13_weight,
139
- self.w13_scale_inv,
140
- self.w2_weight,
141
- self.w2_scale_inv,
142
- )
143
- if topk_ids.numel() <= GATHERED_EXPERT_MAX_ROWS:
144
- return self.forward_gathered(x, topk_ids, topk_weights)
145
- return self.forward_grouped(x, topk_ids, topk_weights)
146
-
147
- def forward_gathered(
148
- self, x: Tensor, topk_ids: Tensor, topk_weights: Tensor
149
- ) -> Tensor:
150
- """One batched matmul over every (token, expert) pair; no host sync."""
151
- tokens, top_k = topk_ids.shape
152
- w13, w2 = self.expert_weights(topk_ids.reshape(-1), x.dtype)
153
- rows = x.repeat_interleave(top_k, dim=0).unsqueeze(-1) # [M, H, 1]
154
- gate, up = torch.bmm(w13, rows).squeeze(-1).chunk(2, dim=-1)
155
- hidden = (F.silu(gate) * up).unsqueeze(-1)
156
- out = torch.bmm(w2, hidden).squeeze(-1) # [M, H]
157
- out = out.float() * topk_weights.reshape(-1, 1).float()
158
- return out.view(tokens, top_k, self.hidden).sum(dim=1).to(x.dtype)
159
-
160
- def forward_grouped(
161
- self, x: Tensor, topk_ids: Tensor, topk_weights: Tensor
162
- ) -> Tensor:
163
- """Loop over the experts the batch hits, each on its own tokens."""
164
- tokens, top_k = topk_ids.shape
165
- flat_ids = topk_ids.reshape(-1)
166
- order = torch.argsort(flat_ids, stable=True)
167
- token_rows = order // top_k
168
- pair_weights = topk_weights.reshape(-1)[order]
169
- counts = torch.bincount(flat_ids, minlength=self.num_experts).tolist()
170
- out = torch.zeros(tokens, self.hidden, dtype=torch.float32, device=x.device)
171
- start = 0
172
- for expert, count in enumerate(counts):
173
- if count == 0:
174
- continue
175
- rows = token_rows[start : start + count]
176
- weights = pair_weights[start : start + count]
177
- start += count
178
- w13, w2 = self.expert_weights(expert, x.dtype)
179
- gate, up = F.linear(x[rows], w13).chunk(2, dim=-1)
180
- expert_out = F.linear(F.silu(gate) * up, w2)
181
- out.index_add_(0, rows, expert_out.float() * weights[:, None].float())
182
- return out.to(x.dtype)
183
-
184
-
185
- class SparseMoeBlock(nn.Module):
186
- """Top-k routed experts plus a sigmoid-gated shared expert."""
187
-
188
- def __init__(
189
- self,
190
- config: FishOmniTextConfig,
191
- *,
192
- fp8: bool,
193
- dtype: torch.dtype | None = None,
194
- device: Device = None,
195
- ) -> None:
196
- super().__init__()
197
- hidden = config.hidden_size
198
- self.top_k = config.num_experts_per_tok
199
- self.norm_topk_prob = config.norm_topk_prob
200
- linear = dict(bias=False, dtype=dtype, device=device)
201
- self.gate = nn.Linear(hidden, config.num_experts, **linear)
202
- self.experts = MoEExperts(
203
- config.num_experts,
204
- hidden,
205
- config.moe_intermediate_size,
206
- fp8=fp8,
207
- dtype=dtype,
208
- device=device,
209
- )
210
- self.shared_expert = DenseMLP(
211
- hidden, config.shared_expert_intermediate_size, dtype=dtype, device=device
212
- )
213
- self.shared_expert_gate = nn.Linear(hidden, 1, **linear)
214
-
215
- def forward(self, x: Tensor) -> Tensor:
216
- flat = x.reshape(-1, x.shape[-1])
217
- probabilities = torch.softmax(self.gate(flat).float(), dim=-1)
218
- topk_weights, topk_ids = probabilities.topk(self.top_k, dim=-1)
219
- if self.norm_topk_prob:
220
- topk_weights = topk_weights / topk_weights.sum(dim=-1, keepdim=True)
221
- out = self.experts(flat, topk_ids, topk_weights.to(flat.dtype))
222
- shared_gate = torch.sigmoid(self.shared_expert_gate(flat))
223
- return (out + shared_gate * self.shared_expert(flat)).view_as(x)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/text2semantic/fish_omni/tower.py DELETED
@@ -1,421 +0,0 @@
1
- import math
2
- from collections.abc import Iterable
3
-
4
- import torch
5
- import torch.nn as nn
6
- import torch.nn.functional as F
7
- from loguru import logger
8
- from torch import Tensor
9
- from torch.types import Device
10
-
11
- from fish_speech.models.text2semantic.fish_omni.config import (
12
- AudioDecoderConfig,
13
- FishOmniConfig,
14
- )
15
- from fish_speech.models.text2semantic.fish_omni.layers import (
16
- ZeroCenteredRMSNorm,
17
- grouped_attention,
18
- )
19
-
20
- # Repetition-aware sampling of the main token, fixed by the training recipe: when
21
- # the first draw repeats one of the last RAS_WINDOW_FRAMES main tokens, a second
22
- # draw with these parameters is used instead.
23
- RAS_WINDOW_FRAMES = 16
24
- RAS_TEMPERATURE = 1.0
25
- RAS_TOP_K = 30
26
- RAS_TOP_P = 0.95
27
- MIN_TEMPERATURE = 1e-5
28
- TOWER_CHECKPOINT_PREFIXES = {
29
- "audio_decoder.codebook_embeddings.": "codebook_embeddings.",
30
- "audio_decoder_mtp_shared.layers.": "shared_layers.",
31
- "audio_decoder_mtp.layers.": "residual_layers.",
32
- "audio_decoder_mtp.heads.": "codebook_heads.",
33
- }
34
-
35
-
36
- def sample_top_k_top_p(
37
- logits: Tensor,
38
- temperature: float | Tensor,
39
- top_k: int | Tensor,
40
- top_p: float | Tensor,
41
- *,
42
- check_finite: bool = True,
43
- ) -> Tensor:
44
- """One id per row; drops the token crossing top_p, except the highest-ranked one.
45
-
46
- Sampling parameters may be 0-dim device tensors (CUDA graphs); check_finite
47
- syncs with the host.
48
- """
49
- if isinstance(temperature, Tensor):
50
- temperature = temperature.clamp(min=MIN_TEMPERATURE)
51
- else:
52
- temperature = max(temperature, MIN_TEMPERATURE)
53
- if check_finite and not torch.isfinite(logits).all():
54
- raise FloatingPointError("non-finite logits; refusing to sample invalid audio")
55
- values, ids = torch.sort(logits.float() / temperature, descending=True, dim=-1)
56
- ranks = torch.arange(values.shape[-1], device=values.device)
57
- dropped = (values.softmax(-1).cumsum(-1) > top_p) | (ranks >= top_k)
58
- dropped[..., 0] = False
59
- probabilities = values.masked_fill(dropped, -torch.inf).softmax(-1)
60
- noise = torch.empty_like(probabilities).exponential_()
61
- choice = (probabilities / noise).argmax(-1, keepdim=True)
62
- return ids.gather(-1, choice).squeeze(-1)
63
-
64
-
65
- class TowerAttention(nn.Module):
66
- """Gated attention over time; checkpoint wqkv rows are [Q | Gate | K | V]."""
67
-
68
- def __init__(
69
- self,
70
- config: AudioDecoderConfig,
71
- *,
72
- dtype: torch.dtype | None = None,
73
- device: Device = None,
74
- ) -> None:
75
- super().__init__()
76
- self.config = config
77
- q_width = config.n_head * config.head_dim
78
- kv_width = config.n_local_heads * config.head_dim
79
- linear = dict(bias=False, dtype=dtype, device=device)
80
- norm = dict(dtype=dtype, device=device)
81
- self.wqkv = nn.Linear(config.dim, 2 * q_width + 2 * kv_width, **linear)
82
- self.wo = nn.Linear(q_width, config.dim, **linear)
83
- # Always on, whatever audio_decoder_config.attention_qk_norm says: training runs
84
- # q/k norm in the audio decoder, but the exporter never writes that field, so
85
- # released Drama 3 configs carry its default false. Keep the (1 + weight) scale too:
86
- # exported weights are zero-centered, not pre-shifted by one.
87
- self.q_norm = ZeroCenteredRMSNorm(config.head_dim, config.norm_eps, **norm)
88
- self.k_norm = ZeroCenteredRMSNorm(config.head_dim, config.norm_eps, **norm)
89
- self.k_cache: Tensor | None = None
90
- self.v_cache: Tensor | None = None
91
- # CUDA-graph decode attends this many cache rows; the mask hides the rest.
92
- self.static_kv_len: int | None = None
93
-
94
- def setup_cache(self, max_seq_len: int) -> None:
95
- shape = (1, self.config.n_local_heads, max_seq_len, self.config.head_dim)
96
- self.k_cache = self.wqkv.weight.new_zeros(shape)
97
- self.v_cache = self.wqkv.weight.new_zeros(shape)
98
-
99
- def rotate(self, x: Tensor, positions: Tensor) -> Tensor:
100
- """Interleaved (non-neox) partial RoPE, computed in fp32."""
101
- width = self.config.rotary_dim
102
- exponents = torch.arange(0, width, 2, device=x.device, dtype=torch.float32)
103
- angles = positions.float()[:, None] * self.config.rope_base ** (
104
- -exponents / width
105
- )
106
- cos, sin = angles.cos()[:, None], angles.sin()[:, None]
107
- even, odd = x[..., :width:2].float(), x[..., 1:width:2].float()
108
- rotated = torch.stack((even * cos - odd * sin, odd * cos + even * sin), -1)
109
- return torch.cat((rotated.flatten(-2).to(x.dtype), x[..., width:]), dim=-1)
110
-
111
- def forward(self, x: Tensor, positions: Tensor) -> Tensor:
112
- cfg = self.config
113
- length = len(positions)
114
- q_width, kv_width = cfg.n_head * cfg.head_dim, cfg.n_local_heads * cfg.head_dim
115
- q, gate, k, v = self.wqkv(x).split((q_width, q_width, kv_width, kv_width), -1)
116
- q = self.q_norm(q.reshape(length, cfg.n_head, cfg.head_dim))
117
- k = self.k_norm(k.reshape(length, cfg.n_local_heads, cfg.head_dim))
118
- q = self.rotate(q, positions).transpose(0, 1).unsqueeze(0)
119
- k = self.rotate(k, positions).transpose(0, 1).unsqueeze(0)
120
- v = v.reshape(length, cfg.n_local_heads, cfg.head_dim)
121
- v = v.transpose(0, 1).unsqueeze(0)
122
- if self.k_cache is not None:
123
- if self.static_kv_len is not None:
124
- end = self.static_kv_len
125
- else:
126
- end = int(positions[-1]) + 1
127
- assert end <= self.k_cache.shape[2], "tower cache capacity exceeded"
128
- self.k_cache.index_copy_(2, positions, k)
129
- self.v_cache.index_copy_(2, positions, v)
130
- k, v = self.k_cache[:, :, :end], self.v_cache[:, :, :end]
131
- key_positions = torch.arange(end, device=x.device)
132
- else:
133
- key_positions = positions
134
- mask = key_positions[None, :] <= positions[:, None]
135
- out = grouped_attention(q, k, v, mask)
136
- out = out.squeeze(0).transpose(0, 1).reshape(length, q_width)
137
- return self.wo(out * gate.sigmoid())
138
-
139
-
140
- class FP32OutputLinear(nn.Linear):
141
- """FP16 GEMM with FP32 output, keeping values beyond the FP16 range."""
142
-
143
- def forward(self, x: Tensor) -> Tensor:
144
- if x.dtype != torch.float16:
145
- return super().forward(x)
146
- if x.is_cuda:
147
- out = torch.mm(
148
- x.reshape(-1, self.in_features), self.weight.T, out_dtype=torch.float32
149
- )
150
- out = out.reshape(*x.shape[:-1], self.out_features)
151
- else:
152
- out = F.linear(x.float(), self.weight.float())
153
- return out if self.bias is None else out + self.bias.float()
154
-
155
-
156
- class TowerMLP(nn.Module):
157
- def __init__(
158
- self,
159
- config: AudioDecoderConfig,
160
- *,
161
- dtype: torch.dtype | None = None,
162
- device: Device = None,
163
- ) -> None:
164
- super().__init__()
165
- linear = dict(bias=False, dtype=dtype, device=device)
166
- self.w1 = nn.Linear(config.dim, config.intermediate_size, **linear)
167
- self.w3 = nn.Linear(config.dim, config.intermediate_size, **linear)
168
- # The checkpoint's down projections exceed 65504 under FP16.
169
- self.w2 = FP32OutputLinear(config.intermediate_size, config.dim, **linear)
170
-
171
- def forward(self, x: Tensor) -> Tensor:
172
- return self.w2(F.silu(self.w1(x)) * self.w3(x))
173
-
174
-
175
- class TowerTransformerLayer(nn.Module):
176
- def __init__(
177
- self,
178
- config: AudioDecoderConfig,
179
- *,
180
- dtype: torch.dtype | None = None,
181
- device: Device = None,
182
- ) -> None:
183
- super().__init__()
184
- args = dict(dtype=dtype, device=device)
185
- self.attention = TowerAttention(config, **args)
186
- self.attention_norm = ZeroCenteredRMSNorm(config.dim, config.norm_eps, **args)
187
- self.ffn_norm = ZeroCenteredRMSNorm(config.dim, config.norm_eps, **args)
188
- self.feed_forward = TowerMLP(config, **args)
189
-
190
- def forward(self, x: Tensor, positions: Tensor) -> Tensor:
191
- dtype = self.attention.wqkv.weight.dtype
192
- residual = x.float() if dtype == torch.float16 else x
193
- residual = residual + self.attention(
194
- self.attention_norm(residual).to(dtype), positions
195
- )
196
- return residual + self.feed_forward(self.ffn_norm(residual).to(dtype))
197
-
198
-
199
- class TowerLayer(nn.Module):
200
- """Mix a code embedding into the hidden state, then one pre-norm block."""
201
-
202
- def __init__(
203
- self,
204
- config: AudioDecoderConfig,
205
- *,
206
- shared: bool,
207
- dtype: torch.dtype | None = None,
208
- device: Device = None,
209
- ) -> None:
210
- super().__init__()
211
- self.shared = shared
212
- args = dict(dtype=dtype, device=device)
213
- # Checkpoint name of the embedding differs between shared / residual layers.
214
- name = "semantic_embedding" if shared else "codebook_embedding"
215
- self.add_module(name, nn.Embedding(config.vocab_size, config.dim, **args))
216
- self.enorm = ZeroCenteredRMSNorm(config.dim, config.norm_eps, **args)
217
- self.hnorm = ZeroCenteredRMSNorm(config.dim, config.norm_eps, **args)
218
- self.eh_proj = nn.Linear(2 * config.dim, config.dim, bias=False, **args)
219
- self.transformer_layer = TowerTransformerLayer(config, **args)
220
- self.final_layernorm = ZeroCenteredRMSNorm(config.dim, config.norm_eps, **args)
221
-
222
- def forward(
223
- self,
224
- hidden: Tensor,
225
- codes: Tensor,
226
- semantic: Tensor,
227
- positions: Tensor,
228
- shared_input: Tensor | None = None,
229
- ) -> Tensor:
230
- embedding = self.semantic_embedding if self.shared else self.codebook_embedding
231
- codes = codes.clamp(0, embedding.num_embeddings - 1)
232
- e = torch.where(semantic[:, None], embedding(codes), 0.0)
233
- if shared_input is not None:
234
- e = e + shared_input
235
- mixed = self.eh_proj(torch.cat((self.enorm(e), self.hnorm(hidden)), dim=-1))
236
- out = self.final_layernorm(self.transformer_layer(mixed, positions))
237
- return out.to(mixed.dtype)
238
-
239
-
240
- class FishOmniAudioDecoder(nn.Module):
241
- """Codebook tower: predicts codes 1..N-1 of each frame, attending over time.
242
-
243
- A frame row is [main token id, code 0, ..., code N-1]; code 0 is the main
244
- token's offset in the semantic band.
245
- """
246
-
247
- def __init__(
248
- self,
249
- config: FishOmniConfig,
250
- *,
251
- dtype: torch.dtype = torch.float16,
252
- device: Device = None,
253
- ) -> None:
254
- super().__init__()
255
- cfg = self.config = config.audio_decoder
256
- self.semantic_begin = config.semantic_start_token_id
257
- self.semantic_end = config.semantic_end_token_id
258
- self.eos_id = config.eos_token_id
259
- args = dict(dtype=dtype, device=device)
260
- self.codebook_embeddings = nn.Embedding(
261
- cfg.vocab_size * cfg.num_codebooks, cfg.dim, **args
262
- )
263
- self.shared_layers = nn.ModuleList(
264
- TowerLayer(cfg, shared=True, **args)
265
- for _ in range(cfg.shared_enrichment_layers)
266
- )
267
- self.residual_layers = nn.ModuleList(
268
- TowerLayer(cfg, shared=False, **args) for _ in range(cfg.num_codebooks - 1)
269
- )
270
- self.codebook_heads = nn.ModuleList(
271
- nn.Linear(cfg.dim, cfg.vocab_size, bias=False, **args)
272
- for _ in range(cfg.num_codebooks - 1)
273
- )
274
- # Ids the main token is drawn from (eos + the semantic band), built lazily
275
- # on the tower's device: creating it inside CUDA-graph capture is illegal.
276
- self.main_candidates: Tensor | None = None
277
-
278
- @property
279
- def device(self) -> torch.device:
280
- return self.codebook_embeddings.weight.device
281
-
282
- def setup_caches(self, max_seq_len: int) -> None:
283
- for layer in (*self.shared_layers, *self.residual_layers):
284
- layer.transformer_layer.attention.setup_cache(max_seq_len)
285
-
286
- def is_semantic(self, tokens: Tensor) -> Tensor:
287
- return (tokens >= self.semantic_begin) & (tokens <= self.semantic_end)
288
-
289
- def embedding_sum(self, frames: Tensor, first: int, end: int) -> Tensor:
290
- """Summed embeddings of codes first..end-1 per frame."""
291
- if first == end:
292
- return self.codebook_embeddings.weight.new_zeros(
293
- (len(frames), self.config.dim)
294
- )
295
- offsets = torch.arange(first, end, device=self.device) * self.config.vocab_size
296
- rows = frames[:, 1 + first : 1 + end] + offsets
297
- return F.embedding_bag(rows, self.codebook_embeddings.weight, mode="sum")
298
-
299
- def embed_backbone(self, frames: Tensor, text_embeddings: Tensor) -> Tensor:
300
- """Backbone input: semantic frames add codes 1..K to the token embedding."""
301
- frames = frames.to(self.device)
302
- count = self.config.vq_backbone_codebooks
303
- extra = self.embedding_sum(frames, 1, 1 + count).to(text_embeddings.device)
304
- mixed = (text_embeddings + extra) / math.sqrt(1 + count)
305
- semantic = self.is_semantic(frames[:, 0]).to(text_embeddings.device)
306
- return torch.where(semantic[:, None], mixed, text_embeddings)
307
-
308
- def run_shared(
309
- self,
310
- hidden: Tensor,
311
- c0: Tensor,
312
- semantic: Tensor,
313
- positions: Tensor,
314
- frames: Tensor,
315
- ) -> Tensor:
316
- # The first shared layer also sees the codes the backbone did not.
317
- unseen = self.embedding_sum(
318
- frames, 1 + self.config.vq_backbone_codebooks, self.config.num_codebooks
319
- )
320
- unseen = torch.where(self.is_semantic(frames[:, 0])[:, None], unseen, 0.0)
321
- for index, layer in enumerate(self.shared_layers):
322
- hidden = layer(
323
- hidden, c0, semantic, positions, unseen if index == 0 else None
324
- )
325
- return hidden
326
-
327
- def prefill(
328
- self, hidden: Tensor, positions: Tensor, frames: Tensor, next_frames: Tensor
329
- ) -> None:
330
- """Teacher-force the tower cache; row p is labelled with frame p + 1."""
331
- semantic = self.is_semantic(next_frames[:, 0])
332
- hidden = self.run_shared(hidden, next_frames[:, 1], semantic, positions, frames)
333
- for index, layer in enumerate(self.residual_layers):
334
- hidden = layer(hidden, next_frames[:, index + 1], semantic, positions)
335
-
336
- def decode_step(
337
- self,
338
- logits: Tensor,
339
- hidden: Tensor,
340
- positions: Tensor,
341
- frames: Tensor,
342
- history: Tensor,
343
- *,
344
- temperature: float | Tensor,
345
- top_k: int | Tensor,
346
- top_p: float | Tensor,
347
- check_finite: bool = True,
348
- ) -> Tensor:
349
- """Sample the frame after each row; the main token is drawn over eos + semantics."""
350
- if self.main_candidates is None or self.main_candidates.device != self.device:
351
- eos = torch.tensor([self.eos_id], device=self.device)
352
- band = torch.arange(
353
- self.semantic_begin, self.semantic_end + 1, device=self.device
354
- )
355
- self.main_candidates = torch.cat((eos, band)).sort().values
356
- candidates = self.main_candidates
357
- candidate_logits = logits[:, candidates]
358
- first = candidates[
359
- sample_top_k_top_p(
360
- candidate_logits, temperature, top_k, top_p, check_finite=check_finite
361
- )
362
- ]
363
- retry = candidates[
364
- sample_top_k_top_p(
365
- candidate_logits,
366
- RAS_TEMPERATURE,
367
- RAS_TOP_K,
368
- RAS_TOP_P,
369
- check_finite=False,
370
- )
371
- ]
372
- # Only audio tokens are resampled, as in S2: the window starts with prompt rows,
373
- # which hold the user turn's eos, and a stop must not be redrawn.
374
- repeated = (history[-RAS_WINDOW_FRAMES:] == first[:, None]).any(-1)
375
- repeated &= self.is_semantic(first)
376
- main = torch.where(repeated, retry, first)
377
- c0 = (main - self.semantic_begin).clamp(0, self.config.vocab_size - 1)
378
- semantic = self.is_semantic(main)
379
- hidden = self.run_shared(hidden, c0, semantic, positions, frames)
380
- codes = [c0]
381
- for layer, head in zip(self.residual_layers, self.codebook_heads):
382
- hidden = layer(hidden, codes[-1], semantic, positions)
383
- codes.append(
384
- sample_top_k_top_p(
385
- head(hidden), temperature, top_k, top_p, check_finite=check_finite
386
- )
387
- )
388
- return torch.stack((main, *codes), dim=-1)
389
-
390
-
391
- @torch.no_grad()
392
- def load_audio_decoder_weights(
393
- model: FishOmniAudioDecoder, weights: Iterable[tuple[str, Tensor]]
394
- ) -> None:
395
- """Copy audio_decoder* checkpoint tensors into model, each parameter exactly once."""
396
- targets = dict(model.named_parameters())
397
- loaded = set()
398
- for key, tensor in weights:
399
- name = next(
400
- (
401
- new + key[len(old) :]
402
- for old, new in TOWER_CHECKPOINT_PREFIXES.items()
403
- if key.startswith(old)
404
- ),
405
- None,
406
- )
407
- if name not in targets:
408
- raise ValueError(f"unknown tower weight {key}")
409
- if name in loaded:
410
- raise ValueError(f"tower weight filled twice: {key}")
411
- target = targets[name]
412
- if target.shape != tensor.shape:
413
- raise ValueError(f"{key}: shape {tensor.shape} != {target.shape}")
414
- value = tensor.to(device=target.device, dtype=target.dtype)
415
- if not torch.isfinite(value).all():
416
- raise FloatingPointError(f"{key} cannot be represented in {target.dtype}")
417
- target.copy_(value)
418
- loaded.add(name)
419
- if missing := targets.keys() - loaded:
420
- raise ValueError(f"tower weights not loaded: {sorted(missing)[:8]}")
421
- logger.info(f"FishOmni tower: loaded {len(loaded)} tensors on {model.device}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fish_speech/models/text2semantic/sampling.py DELETED
@@ -1,9 +0,0 @@
1
- """Sampling defaults for S2-Pro and Drama 3: those of the official Fish Audio SDK
2
- (fish-audio-sdk TTSConfig). The SDK has no top_k; both models use 30."""
3
-
4
- DEFAULT_MAX_NEW_TOKENS = 1024 # per text chunk
5
- DEFAULT_TEMPERATURE = 0.7
6
- DEFAULT_TOP_P = 0.7
7
- DEFAULT_TOP_K = 30
8
- DEFAULT_REPETITION_PENALTY = 1.2
9
- DEFAULT_CHUNK_LENGTH = 200
 
 
 
 
 
 
 
 
 
 
footer.html ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <section class="card">
2
+
3
+ <section class="credits" aria-labelledby="acknowledgments-title">
4
+ <h2 id="acknowledgments-title">Acknowledgments</h2>
5
+ <p>It took us over half a year to bring SGLang Omni back to life. Thanks to all the people who helped us along the way.</p>
6
+ <p>Thanks so much to <a href="https://huggingface.co/andito" target="_blank" rel="noreferrer">andito</a>, <a href="https://x.com/_akhaliq" target="_blank" rel="noreferrer">AK</a>, and <a href="https://huggingface.co/multimodalart" target="_blank" rel="noreferrer">poli</a> for helping set up the Space.</p>
7
+ <p><b>SGLang Omni team:</b> <a href="https://x.com/GenAI_is_real" target="_blank" rel="noreferrer">Chenyang Zhao</a>, Jingwen Gu, Shenggui Li, Richard Wang, Yichi Zhang, Xuesong Ye, Jiaxin Deng, Ratish P, Jiaxuan Luo, Xinyu Lu, Wenyao Gao, Ao Sun, Jintao Quaige Li, Yuhao Chen, Yuhan Zhou, Xuxiang Tian, Ruoyi Zhang, Junnan Li, Yixiao Zeng, Chenchen Hong, Huapeng Zhou, Yang Gao, Chaoye Fei, Yiyang Zh, Kang, Yitian Gong, Yuan Luo, Hao Jin, Yifei Gao, Gaokai Zhang, Lifan Shen, Hui Shen, Kejing Xia, Jinxin Li, Dongming Shen, Jun Liu, Yue Yin, Zhihao Guo, Jisen Li, Ruoyu He, Xuanyi Li, Chenye Zhu, Liang Zhen, Xiaodong Ye, Yizhi Zhen, Kaizuo Li, Chonghan Liu, Wei Guan, Zhuofeng Li</p>
8
+ <p><b>FishAudio Team:</b> Shijia Liao, Shidong Li, Xuehao Yang, Jiahua Liu</p>
9
+ </section>
10
+ <p class="fine" style="margin-top:10px">Model license: <a href="https://huggingface.co/fishaudio/drama-3/blob/main/LICENSE" target="_blank" rel="noreferrer">Fish Audio Community License</a>, for research and non-commercial use. Takes and uploaded voices live in server memory only and expire.</p>
11
+ </section>
gradio_ui.py ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio form, classic streaming stage, and a separately mounted gr.Workflow canvas."""
2
+ import asyncio
3
+ import base64
4
+ from contextlib import aclosing
5
+ import html
6
+ import json
7
+ import os
8
+ import secrets
9
+ from pathlib import Path
10
+
11
+ import anyio
12
+ from fastapi import HTTPException
13
+ import gradio as gr
14
+ import numpy as np
15
+ from pydantic import ValidationError
16
+ from starlette.middleware.sessions import SessionMiddleware
17
+ from starlette.datastructures import UploadFile
18
+
19
+ ROOT = Path(__file__).resolve().parent
20
+ OUTPUT_DIR = Path(os.environ.get("GRADIO_TEMP_DIR", "/tmp/drama3-gradio"))
21
+ PRESETS = json.loads((ROOT / "voices/voices.json").read_text())
22
+ CONTENT = json.loads((ROOT / "studio_content.json").read_text())
23
+ DEFAULT_DESIGN = CONTENT["CAST"][0]
24
+
25
+
26
+ async def events(response):
27
+ """Consume SSE without running a second HTTP server or bypassing admission."""
28
+ pending = ""
29
+ async with aclosing(response.body_iterator):
30
+ async for chunk in response.body_iterator:
31
+ pending += chunk.decode() if isinstance(chunk, bytes) else chunk
32
+ while "\n\n" in pending:
33
+ raw, pending = pending.split("\n\n", 1)
34
+ for line in raw.splitlines():
35
+ if line.startswith("data: "):
36
+ yield json.loads(line[6:])
37
+
38
+
39
+ class Studio:
40
+ def __init__(self, backend):
41
+ self.backend = backend
42
+
43
+ async def identity(self, request):
44
+ if request is None or request.request is None:
45
+ raise gr.Error("Open the studio in your browser before generating a take.")
46
+ try:
47
+ return await asyncio.to_thread(self.backend.identify, request.request)
48
+ except HTTPException as error:
49
+ raise gr.Error(str(error.detail)) from error
50
+
51
+ async def response(self, body, request):
52
+ try:
53
+ identity = await self.identity(request)
54
+ if os.environ.get("DEMO_PREVIEW") == "1":
55
+ raise gr.Error("This is a UI preview. Start the GPU engine to perform a take.")
56
+ return await self.backend.generate(body, identity)
57
+ except HTTPException as error:
58
+ raise gr.Error(str(error.detail)) from error
59
+
60
+ def body(self, text, mode, voice, transcript, name, description, scene,
61
+ temperature, top_p, top_k, retake_of=None, note=None):
62
+ try:
63
+ return self.backend.GenerateBody(
64
+ text=text, voice=self.backend.VoiceBody(
65
+ mode={"Clone": "clone", "Design": "design", "Surprise": "random"}[mode],
66
+ id=voice, transcript=transcript, name=name,
67
+ description=description, scene=scene),
68
+ temperature=temperature, top_p=top_p, top_k=top_k,
69
+ retake_of=retake_of, note=note)
70
+ except (ValidationError, KeyError) as error:
71
+ raise gr.Error("Check the voice mode and sampling settings.") from error
72
+
73
+ async def run(self, body, current, history, request):
74
+ response = await self.response(body, request)
75
+ rate = self.backend.SAMPLE_RATE
76
+ pending = bytearray()
77
+ done = False
78
+ async with aclosing(events(response)) as stream:
79
+ async for event in stream:
80
+ kind = event["type"]
81
+ if kind == "error":
82
+ raise gr.Error(event["message"])
83
+ if kind == "queued":
84
+ yield gr.skip(), gr.skip(), gr.skip(), f"Waiting for the stage · position {event['position']}", gr.skip()
85
+ elif kind == "retrying":
86
+ yield gr.skip(), gr.skip(), gr.skip(), event["message"], gr.skip()
87
+ elif kind == "start":
88
+ rate = event["sample_rate"]
89
+ yield None, gr.skip(), gr.skip(), "Preparing the actor…", gr.skip()
90
+ elif kind == "audio":
91
+ pending.extend(base64.b64decode(event["pcm"]))
92
+ # Gradio re-encodes each chunk for its native streaming player.
93
+ # Half-second chunks avoid a re-encode per 46 ms engine frame.
94
+ if len(pending) >= rate:
95
+ chunk = self.backend.wav_bytes(self.backend.pcm_to_float(bytes(pending)), rate)
96
+ pending.clear()
97
+ yield chunk, gr.skip(), gr.skip(), "On air…", gr.skip()
98
+ elif kind == "done":
99
+ done = True
100
+ audio = (self.backend.wav_bytes(self.backend.pcm_to_float(bytes(pending)), rate)
101
+ if pending else gr.skip())
102
+ entries = list(history or []) + [event["take_id"]]
103
+ choices = [(f"Take {i + 1}", key) for i, key in enumerate(entries)]
104
+ metrics = (f"First audio **{event['ttfa_ms']} ms** · "
105
+ f"**{event['audio_s']:.1f} s** audio · **{event['speed']}×** real time")
106
+ if event.get("warning"):
107
+ metrics += "\n\n" + event["warning"]
108
+ yield audio, event["take_id"], entries, metrics, gr.Dropdown(choices=choices, value=event["take_id"])
109
+ if not done:
110
+ raise gr.Error("The take ended before it finished. Try again.")
111
+
112
+ async def stage_run(self, body, current, history, request):
113
+ """Stream a PCM packet ledger to the custom Gradio stage.
114
+
115
+ Each update includes all packets so browser render batching cannot lose a
116
+ chunk. Gradio sends array diffs, and the player consumes each packet once.
117
+ """
118
+ previous = self.backend.store.take(body.retake_of) if body.retake_of else None
119
+ choice = previous.choice if previous else None
120
+ mode = choice.mode if choice else body.voice.mode
121
+ voice = choice.voice if choice and mode == "clone" else self.backend.store.voice(body.voice.id or "")
122
+ character = choice.character.name if choice and choice.character else body.voice.name
123
+ label = voice.label if mode == "clone" and voice else character if mode == "design" else "Surprise"
124
+ common = {"run_id": secrets.token_urlsafe(9), "text": body.text, "mode": mode,
125
+ "voice": label or "Narrator", "note": body.note if previous else None}
126
+ packets, pending = [], bytearray()
127
+ rate, sequence, done = self.backend.SAMPLE_RATE, 0, False
128
+
129
+ def update(event):
130
+ nonlocal sequence
131
+ sequence += 1
132
+ return {**common, **event, "sequence": sequence, "packets": list(packets)}
133
+
134
+ yield update({"type": "preparing"}), gr.skip(), gr.skip()
135
+ try:
136
+ response = await self.response(body, request)
137
+ async with aclosing(events(response)) as stream:
138
+ async for event in stream:
139
+ kind = event["type"]
140
+ if kind == "start":
141
+ rate = event["sample_rate"]
142
+ if kind == "audio":
143
+ pending.extend(base64.b64decode(event["pcm"]))
144
+ if len(pending) < rate:
145
+ continue
146
+ packets.append(base64.b64encode(pending).decode("ascii"))
147
+ pending.clear()
148
+ event = {k: v for k, v in event.items() if k != "pcm"}
149
+ event["sample_rate"] = rate
150
+ elif kind in {"done", "error"}:
151
+ if pending:
152
+ packets.append(base64.b64encode(pending).decode("ascii"))
153
+ pending.clear()
154
+ event["sample_rate"] = rate
155
+ done = True
156
+ if kind == "done":
157
+ entries = list(history or []) + [event["take_id"]]
158
+ yield update(event), event["take_id"], entries
159
+ else:
160
+ yield update(event), gr.skip(), gr.skip()
161
+ if not done:
162
+ yield update({"type": "error", "message": "The take ended before it finished. Try again."}), gr.skip(), gr.skip()
163
+ except gr.Error as error:
164
+ yield update({"type": "error", "message": str(error)}), gr.skip(), gr.skip()
165
+
166
+ async def stage_perform(self, text, mode, voice, transcript, name, description, scene,
167
+ temperature, top_p, top_k, current, history, request: gr.Request):
168
+ try:
169
+ body = self.body(text, mode, voice, transcript, name, description, scene, temperature, top_p, top_k)
170
+ except gr.Error as error:
171
+ yield {"type": "error", "message": str(error)}, gr.skip(), gr.skip()
172
+ return
173
+ async with aclosing(self.stage_run(body, current, history, request)) as stream:
174
+ async for result in stream:
175
+ yield result
176
+
177
+ async def stage_retake(self, text, mode, voice, transcript, name, description, scene,
178
+ temperature, top_p, top_k, current, history, note, request: gr.Request):
179
+ try:
180
+ if not current:
181
+ raise gr.Error("Perform a take first, then give the actor a note.")
182
+ body = self.body(text, mode, voice, transcript, name, description, scene,
183
+ temperature, top_p, top_k, current, note)
184
+ except gr.Error as error:
185
+ yield {"type": "error", "message": str(error)}, gr.skip(), gr.skip()
186
+ return
187
+ async with aclosing(self.stage_run(body, current, history, request)) as stream:
188
+ async for result in stream:
189
+ yield result
190
+
191
+ async def stage_action(self, choices, evt: gr.EventData, request: gr.Request):
192
+ """Native Gradio events from the stage's keep/replay controls."""
193
+ try:
194
+ await self.identity(request)
195
+ take_id = evt.take_id
196
+ if evt.action == "keep":
197
+ kept = await self.keep(take_id, choices, request)
198
+ return (*kept, gr.skip(), gr.skip(), {"type": "notice", "message": "Saved as a clone voice. New lines will keep this voice."})
199
+ if evt.action == "select":
200
+ take = self.backend.store.take(take_id)
201
+ if take is None:
202
+ raise gr.Error("That take has expired. Perform a fresh one.")
203
+ return (*[gr.skip() for _ in range(8)], take_id, take.line, {"type": "selected", "take_id": take_id})
204
+ raise gr.Error("Unknown stage action.")
205
+ except gr.Error as error:
206
+ return (*[gr.skip() for _ in range(10)], {"type": "notice", "error": True, "message": str(error)})
207
+
208
+ async def perform(self, text, mode, voice, transcript, name, description, scene,
209
+ temperature, top_p, top_k, current, history, request: gr.Request):
210
+ body = self.body(text, mode, voice, transcript, name, description, scene, temperature, top_p, top_k)
211
+ async with aclosing(self.run(body, current, history, request)) as stream:
212
+ async for result in stream:
213
+ yield result
214
+
215
+ async def retake(self, text, mode, voice, transcript, name, description, scene,
216
+ temperature, top_p, top_k, current, history, note, request: gr.Request):
217
+ if not current:
218
+ raise gr.Error("Perform a take first, then give the actor a note.")
219
+ body = self.body(text, mode, voice, transcript, name, description, scene,
220
+ temperature, top_p, top_k, current, note)
221
+ async with aclosing(self.run(body, current, history, request)) as stream:
222
+ async for result in stream:
223
+ yield result
224
+
225
+ async def voice_details(self, voice_id, request: gr.Request):
226
+ await self.identity(request)
227
+ voice = self.backend.store.voice(voice_id or "")
228
+ if voice is None:
229
+ raise gr.Error("That voice has expired. Upload it again.")
230
+ wav, rate = self.backend.decode_audio(voice.wav)
231
+ return (rate, wav), voice.transcript
232
+
233
+ async def upload(self, path, choices, request: gr.Request):
234
+ await self.identity(request)
235
+ if not path:
236
+ return gr.skip(), gr.skip(), gr.skip(), gr.skip()
237
+ path = Path(path)
238
+ if path.stat().st_size > self.backend.UPLOAD_MAX_BYTES:
239
+ raise gr.Error("Use an audio clip under 25 MB.")
240
+ try:
241
+ with path.open("rb") as handle:
242
+ voice = await self.backend.add_voice(UploadFile(handle, filename=path.name))
243
+ except HTTPException as error:
244
+ raise gr.Error(str(error.detail)) from error
245
+ return self.voice_update(voice, choices)
246
+
247
+ def voice_update(self, voice, choices):
248
+ entries = list(choices or [])
249
+ pair = (voice["label"], voice["id"])
250
+ entries = [p for p in entries if p[1] != voice["id"]] + [pair]
251
+ stored = self.backend.store.voice(voice["id"])
252
+ wav, rate = self.backend.decode_audio(stored.wav)
253
+ return gr.Radio(choices=entries, value=voice["id"]), (rate, wav), voice["transcript"], entries
254
+
255
+ async def keep(self, take_id, choices, request: gr.Request):
256
+ await self.identity(request)
257
+ if not take_id:
258
+ raise gr.Error("Perform a take first, then keep its voice.")
259
+ try:
260
+ voice = await self.backend.voice_from_take(self.backend.FromTake(take_id=take_id))
261
+ except HTTPException as error:
262
+ raise gr.Error(str(error.detail)) from error
263
+ return (*self.voice_update(voice, choices), gr.Radio(value="Clone"), gr.Column(visible=True),
264
+ gr.Column(visible=False), gr.Column(visible=False))
265
+
266
+ def take_path(self, take):
267
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
268
+ path = OUTPUT_DIR / f"drama3-{take.id}.wav"
269
+ if not path.exists():
270
+ path.write_bytes(self.backend.wav_bytes(self.backend.pcm_to_float(take.pcm), take.sample_rate))
271
+ return str(path)
272
+
273
+ async def recall(self, take_id, request: gr.Request):
274
+ await self.identity(request)
275
+ take = self.backend.store.take(take_id or "")
276
+ if take is None:
277
+ raise gr.Error("That take has expired. Perform a fresh one.")
278
+ return self.take_path(take), take_id, take.line
279
+
280
+ async def status(self, request: gr.Request):
281
+ try:
282
+ identity = await self.identity(request)
283
+ state = await self.backend.status(identity)
284
+ except gr.Error as error:
285
+ return (f'<div class="hero-top"><span class="pill">{html.escape(str(error))}</span>'
286
+ '<a class="pill signin" href="/oauth/huggingface/login" target="_blank" rel="noopener">Sign in with Hugging Face</a></div>',
287
+ gr.Button(interactive=False), gr.skip())
288
+ engine = state["engine"]
289
+ ready = engine["state"] == "ready"
290
+ phase = "UI preview · GPU engine offline" if os.environ.get("DEMO_PREVIEW") == "1" else engine["phase"]
291
+ gpu = state.get("gpu") or {}
292
+ label = gpu.get("name") or "Drama 3"
293
+ usage = state["usage"]
294
+ quota = "Unlimited takes" if usage["unlimited"] else f"{usage['remaining']} takes left today"
295
+ if identity.signed_in:
296
+ account = (f'<details class="account"><summary class="pill">{html.escape(identity.name)} · {quota}</summary>'
297
+ '<a href="/oauth/huggingface/logout">Sign out</a></details>')
298
+ elif os.environ.get("SPACE_ID"):
299
+ account = f'<a class="pill signin" href="/oauth/huggingface/login" target="_blank" rel="noopener">Sign in · {quota}</a>'
300
+ else:
301
+ account = '<span class="pill">Local studio</span>'
302
+ p50 = state["stats"]["ttfa_ms_p50"]
303
+ timing = f" · {p50} ms first audio" if p50 is not None else ""
304
+ banner = (f'<div class="hero-top"><span class="pill"><i class="dot {"ready" if ready else "busy"}"></i>'
305
+ f'{html.escape(label)}{timing}</span><span class="spacer"></span>{account}</div>')
306
+ boot = gr.HTML(value=f'<div class="boot"><b>Setting the stage…</b><p>{html.escape(phase)}</p></div>', visible=not ready)
307
+ return banner, gr.Button(value="● Perform" if ready else "Warming up…", interactive=ready), boot
308
+
309
+ async def workflow_generate(self, text, mode, voice_id, description, request):
310
+ body = self.body(text, mode, voice_id, None, "Narrator", description, None, 1.0, 0.9, 0)
311
+ response = await self.response(body, request)
312
+ take = None
313
+ async with aclosing(events(response)) as stream:
314
+ async for event in stream:
315
+ if event["type"] == "error":
316
+ raise gr.Error(event["message"])
317
+ if event["type"] == "done":
318
+ take = self.backend.store.take(event["take_id"])
319
+ if take is None:
320
+ raise gr.Error("No completed take came back from the engine.")
321
+ path = self.take_path(take)
322
+ from gradio_client.utils import encode_file_path
323
+ return {"path": path, "url": f"/workflow/gradio_api/file={encode_file_path(path)}", "is_file": True}
324
+
325
+
326
+ def build_apps(backend):
327
+ studio = Studio(backend)
328
+ voice_choices = [(entry["label"], entry["id"]) for entry in PRESETS]
329
+ with gr.Blocks(title="Fish Audio Drama 3", analytics_enabled=False, fill_width=True, delete_cache=(3600, 86400)) as demo:
330
+ current = gr.State(None)
331
+ history = gr.State([])
332
+ voices = gr.State(voice_choices)
333
+ with gr.Column(elem_id="studio"):
334
+ with gr.Row(elem_id="topbar"):
335
+ banner = gr.HTML(scale=12)
336
+ theme = gr.Button("◐", min_width=32, scale=0, elem_id="theme-toggle", size="sm")
337
+ gr.HTML((ROOT / "hero.html").read_text())
338
+ with gr.Tabs(elem_id="app-tabs"):
339
+ with gr.Tab("Studio"):
340
+ boot = gr.HTML()
341
+ with gr.Column(elem_classes="card"):
342
+ gr.HTML('<div class="card-head"><h2>Script</h2></div>')
343
+ with gr.Row(elem_classes="examples"):
344
+ examples = [gr.Button(item["label"], size="sm", min_width=85) for item in CONTENT["EXAMPLES"]]
345
+ script = gr.Textbox(value=CONTENT["EXAMPLES"][0]["text"], label="Script", show_label=False,
346
+ lines=4, max_lines=10, elem_id="script", placeholder="Write a line. Give it some direction.")
347
+ with gr.Accordion("Direction tags", open=False, elem_id="direction-tags"):
348
+ count = gr.Markdown("Tags in `[ ]` and `< >` are direction, not words. Any wording works.", elem_classes="hint")
349
+ palette = []
350
+ for title, key, cls in [("Events", "POINT_TAGS", "point"), ("Span", "SCOPED_TAGS", "scope"), ("Mood", "STYLES", "mood")]:
351
+ with gr.Row(elem_classes=["palette", cls]):
352
+ gr.HTML(f'<span class="plabel">{title}</span>', min_width=60, scale=0)
353
+ for tag in CONTENT[key]:
354
+ button = gr.Button(tag, size="sm", min_width=40, elem_classes="chip")
355
+ palette.append((button, tag, cls))
356
+ with gr.Accordion("How to direct", open=False):
357
+ gr.Markdown('**Point tag:** `Well [sighs] fine, you win. [laughs]`\n\n'
358
+ '**Scoped tag:** `Promise me <whisper>you won’t tell her</whisper>.`\n\n'
359
+ '**Style prefix:** `[icy calm] Fine.`\n\n'
360
+ '**Director’s note:** `Direction: put the rise on "goodbye".` followed by `Line: So this is goodbye.`')
361
+ with gr.Column(elem_classes="card"):
362
+ gr.HTML('<div class="card-head"><h2>Voice</h2></div>')
363
+ mode = gr.Radio([("🎲 Surprise", "Surprise"), ("🎙 Clone", "Clone"), ("✨ Design", "Design")],
364
+ value="Surprise", show_label=False, container=False, elem_id="voice-mode")
365
+ with gr.Column(visible=False) as clone_panel:
366
+ voice = gr.Radio(voice_choices, value="clone1", show_label=False, container=False, elem_id="voice-presets")
367
+ preview = gr.Audio(label="Reference voice", interactive=False, type="numpy", buttons=[])
368
+ with gr.Accordion("Upload or record a voice", open=False):
369
+ upload = gr.Audio(sources=["upload", "microphone"], type="filepath", label="Upload or record a voice", format="wav")
370
+ transcript = gr.Textbox(label="What the clip says", lines=2, value=PRESETS[0]["transcript"])
371
+ gr.Markdown("Clips are trimmed to 20 s and transcribed for you. Only clone voices you have permission to use.", elem_classes="hint")
372
+ with gr.Column(visible=False) as design_panel:
373
+ with gr.Row(elem_classes="examples"):
374
+ cast_buttons = [gr.Button(item["label"], size="sm", min_width=90) for item in CONTENT["CAST"]]
375
+ with gr.Row():
376
+ name = gr.Textbox(label="Character", value=DEFAULT_DESIGN["name"], scale=1)
377
+ description = gr.Textbox(label="Voice description", lines=2, value=DEFAULT_DESIGN["desc"], scale=3)
378
+ scene = gr.Textbox(label="Scene (optional)", value=DEFAULT_DESIGN["scene"])
379
+ gr.Markdown("Describe age, timbre, accent, and energy. Like a take? **Keep this voice** turns it into a clone.", elem_classes="hint")
380
+ with gr.Column() as surprise_panel:
381
+ gr.Markdown("Drama 3 improvises a voice that fits the line. Every take is a stranger, so hit **Keep this voice** when you meet one you like.", elem_classes="hint")
382
+ with gr.Accordion("Sampling", open=False):
383
+ temperature = gr.Slider(.3, 1.3, value=1.0, step=.05, label="Temperature")
384
+ top_p = gr.Slider(.5, 1, value=.9, step=.01, label="Top-p")
385
+ top_k = gr.Slider(0, 100, value=0, step=1, label="Top-k",
386
+ info="0 samples from every code. Low values make takes go silent mid-line.")
387
+ reset = gr.Button("Reset sampling", size="sm")
388
+ with gr.Row():
389
+ perform = gr.Button("Warming up…", variant="primary", interactive=False, elem_id="perform", scale=5)
390
+ stop = gr.Button("Stop", scale=1, elem_id="stop")
391
+ with gr.HTML(value={"type": "idle", "packets": []},
392
+ html_template=(ROOT / "stage.html").read_text(),
393
+ css_template=(ROOT / "stage.css").read_text(),
394
+ js_on_load=(ROOT / "stage.js").read_text(),
395
+ apply_default_css=False, elem_id="the-stage") as stage:
396
+ with gr.Column(elem_classes="stage-direct"):
397
+ gr.HTML('<h3>🎬 Give a note</h3><p class="hint">The actor hears this take, then redoes the line with your note.</p>')
398
+ with gr.Row(elem_classes=["examples", "note-chips"]):
399
+ note_buttons = [gr.Button(note, size="sm", min_width=60) for note in CONTENT["NOTES"]]
400
+ with gr.Row(elem_classes="stage-note-row"):
401
+ note = gr.Textbox(show_label=False, placeholder="e.g. more amused than before; drop the hurt", scale=4, elem_id="stage-note")
402
+ retake = gr.Button("Retake", variant="primary", scale=1, elem_id="retake")
403
+ # Keep the existing native audio API for clients and Workflow.
404
+ audio = gr.Audio(visible=False, streaming=True, format="wav", interactive=False)
405
+ metrics = gr.Markdown(visible=False)
406
+ slate = gr.Dropdown(choices=[], visible=False)
407
+ api_perform = gr.Button(visible=False)
408
+ api_retake = gr.Button(visible=False)
409
+ api_keep = gr.Button(visible=False)
410
+ with gr.Tab("Workflow", id="workflow"):
411
+ gr.HTML('<iframe title="Drama 3 Workflow" src="/workflow/" loading="lazy" allow="microphone; autoplay; clipboard-write" style="width:100%;height:780px;border:0;border-radius:16px"></iframe>')
412
+ gr.Markdown('[Open workflow full screen](/workflow/)', elem_classes="hint")
413
+ gr.HTML((ROOT / "footer.html").read_text())
414
+
415
+ for button, item in zip(examples, CONTENT["EXAMPLES"]):
416
+ button.click(lambda text=item["text"]: text, outputs=script, queue=False, api_visibility="private")
417
+ for button, item in zip(cast_buttons, CONTENT["CAST"]):
418
+ button.click(lambda item=item: (item["name"], item["desc"], item["scene"], item["text"]),
419
+ outputs=[name, description, scene, script], queue=False, api_visibility="private")
420
+ for button, value in zip(note_buttons, CONTENT["NOTES"]):
421
+ button.click(lambda value=value: value, outputs=note, queue=False, api_visibility="private")
422
+ for button, tag, kind in palette:
423
+ # Insert at the native textbox's selection and dispatch an input event.
424
+ button.click(None, inputs=script, outputs=script, queue=False,
425
+ js=f"(text) => window.dramaInsert(text, {json.dumps(tag)}, {json.dumps(kind)})", api_visibility="private")
426
+ script.input(lambda text: f"Tags in `[ ]` and `< >` are direction, not words. · **{len(text)} / 600**",
427
+ inputs=script, outputs=count, queue=False, api_visibility="private")
428
+ mode.change(lambda m: tuple(gr.Column(visible=m == value) for value in ["Clone", "Design", "Surprise"]),
429
+ inputs=mode, outputs=[clone_panel, design_panel, surprise_panel], queue=False, api_visibility="private")
430
+ voice.change(studio.voice_details, inputs=voice, outputs=[preview, transcript], api_visibility="private")
431
+ upload.change(studio.upload, inputs=[upload, voices], outputs=[voice, preview, transcript, voices], api_visibility="private")
432
+ stage.click(studio.stage_action, inputs=voices,
433
+ outputs=[voice, preview, transcript, voices, mode, clone_panel, design_panel, surprise_panel, current, script, stage],
434
+ queue=False, api_visibility="private", show_progress="hidden")
435
+ slate.input(studio.recall, inputs=slate, outputs=[audio, current, script], api_visibility="private")
436
+ reset.click(lambda: (1.0, .9, 0), outputs=[temperature, top_p, top_k], queue=False, api_visibility="private")
437
+ theme.click(None, js="() => window.dramaToggleTheme()", queue=False, api_visibility="private")
438
+ inputs = [script, mode, voice, transcript, name, description, scene, temperature, top_p, top_k, current, history]
439
+ outputs = [audio, current, history, metrics, slate]
440
+ unlock = "(...args) => { window.dramaStage?.unlock(); return args; }"
441
+ generation = perform.click(studio.stage_perform, inputs=inputs, outputs=[stage, current, history],
442
+ js=unlock, concurrency_limit=None, api_visibility="private", show_progress="hidden")
443
+ directing = retake.click(studio.stage_retake, inputs=[*inputs, note], outputs=[stage, current, history],
444
+ js=unlock, concurrency_limit=None, api_visibility="private", show_progress="hidden")
445
+ api_perform.click(studio.perform, inputs=inputs, outputs=outputs, concurrency_limit=None, api_name="perform", show_progress="hidden")
446
+ api_retake.click(studio.retake, inputs=[*inputs, note], outputs=outputs, concurrency_limit=None, api_name="retake", show_progress="hidden")
447
+ api_keep.click(studio.keep, inputs=[current, voices], outputs=[voice, preview, transcript, voices, mode, clone_panel, design_panel, surprise_panel], api_name="keep_voice", show_progress="hidden")
448
+ stop.click(None, cancels=[generation, directing], queue=False, api_visibility="private", js="() => window.dramaStage?.stop()")
449
+ demo.load(studio.voice_details, inputs=voice, outputs=[preview, transcript], api_visibility="private")
450
+ demo.load(studio.status, outputs=[banner, perform, boot], api_visibility="private", show_progress="hidden")
451
+ gr.Timer(5).tick(studio.status, outputs=[banner, perform, boot], queue=False, api_visibility="private", show_progress="hidden")
452
+
453
+ # Workflow invokes functions from AnyIO workers. Bridge back to the app loop:
454
+ # the existing HTTP connection pool and admission events belong to that loop.
455
+ def direct_performance(text: str, mode: str, voice_id: str, description: str, request: gr.Request) -> dict:
456
+ return anyio.from_thread.run(studio.workflow_generate, text, mode, voice_id, description, request)
457
+
458
+ graph_path = Path(os.environ.get("WORKFLOW_GRAPH", str(OUTPUT_DIR / "workflow.json")))
459
+ graph_path.parent.mkdir(parents=True, exist_ok=True)
460
+ if not graph_path.exists():
461
+ graph_path.write_text((ROOT / "workflow.json").read_text())
462
+ workflow = gr.Workflow(graph=str(graph_path), bind={"direct_performance": direct_performance})
463
+ demo.queue(default_concurrency_limit=backend.MAX_ACTIVE, max_size=backend.MAX_QUEUE)
464
+ workflow.queue(default_concurrency_limit=backend.MAX_ACTIVE, max_size=backend.MAX_QUEUE)
465
+ return demo, workflow
466
+
467
+
468
+ def mount_apps(backend):
469
+ demo, workflow = build_apps(backend)
470
+ app = gr.mount_gradio_app(backend.app, workflow, path="/workflow", allowed_paths=[str(OUTPUT_DIR)],
471
+ footer_links=[], max_file_size="25mb", ssr_mode=False)
472
+ # The parent already signs the HF session. A second signer would hide the
473
+ # logged-in account from quotas and break the shared Studio/Workflow login.
474
+ workflow.app.user_middleware = [m for m in workflow.app.user_middleware if m.cls is not SessionMiddleware]
475
+ workflow.app.middleware_stack = None
476
+ app = gr.mount_gradio_app(app, demo, path="/", footer_links=[], max_file_size="25mb", ssr_mode=False,
477
+ css=(ROOT / "studio.css").read_text(), js=(ROOT / "studio.js").read_text(),
478
+ theme=gr.themes.Base(primary_hue="neutral", secondary_hue="neutral", neutral_hue="stone", radius_size="lg"))
479
+ return app
hero.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <header class="hero">
2
+ <h1>🎭 Drama 3</h1>
3
+ <p>by Fish Audio · served with SGLang Omni</p>
4
+ <p>Write a line, pick a voice, and <strong>direct the performance</strong>: drop in a <code>[laughs]</code>, whisper a phrase, or describe a voice that doesn't exist yet. Then give the actor a note and get another take. The 35B MoE streams from a single GPU as it generates.</p>
5
+ <nav class="links resource-links" aria-label="Drama 3 resources">
6
+ <a href="https://sgl-project.github.io/sglang-omni/cookbook/fishaudio_drama3.html" target="_blank" rel="noreferrer">SGLang Omni cookbook</a>
7
+ <a href="https://github.com/sgl-project/sglang-omni" target="_blank" rel="noreferrer">⭐ Star SGLang Omni</a>
8
+ <a href="https://github.com/fishaudio/fish-speech/tree/main/docs/assets/Fish_Audio_Drama_3_Technical_Report.pdf" target="_blank" rel="noreferrer">Technical report</a>
9
+ <a href="https://fish.audio" target="_blank" rel="noreferrer">Fish Audio</a>
10
+ <a href="https://github.com/fishaudio/fish-speech" target="_blank" rel="noreferrer">Fish Speech</a>
11
+ <a href="https://huggingface.co/fishaudio/drama-3" target="_blank" rel="noreferrer">Model card</a>
12
+ </nav>
13
+ </header>
index.html ADDED
@@ -0,0 +1,1210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Fish Audio Drama 3</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #f6f5f3;
12
+ --card: #ffffff;
13
+ --soft: #efede9;
14
+ --field: #ffffff;
15
+ --ink: #141414;
16
+ --muted: #6b6b66;
17
+ --faint: #a3a19b;
18
+ --border: #e2e0dc;
19
+ --card-border: #e8e6e2;
20
+ --accent: #141414;
21
+ --accent-ink: #f6f5f3;
22
+ --accent-soft: rgba(20, 20, 20, 0.08);
23
+ --moment-bg: #fbe9c4; --moment-ink: #7d4b00;
24
+ --scope-bg: #d9ebfa; --scope-ink: #0b5687;
25
+ --mood-bg: #ebe1fa; --mood-ink: #56289e;
26
+ --note-bg: #e3f1e3; --note-ink: #2a662d;
27
+ --ok: #1f9d55;
28
+ --warn: #b7791f;
29
+ --err: #d64545;
30
+ --hover: brightness(0.96);
31
+ --shadow: 0 1px 2px rgba(20, 20, 20, 0.04);
32
+ --radius: 16px;
33
+ --sans: ui-sans-serif, -apple-system, "Segoe UI", Inter, Roboto, sans-serif;
34
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
35
+ color-scheme: light;
36
+ }
37
+ @media (prefers-color-scheme: dark) {
38
+ :root:not([data-theme="light"]) {
39
+ --bg: #161615; --card: #201f1d; --soft: #2a2826; --field: #1a1918; --ink: #f3f2ef; --muted: #a8a7a1;
40
+ --faint: #6f6d68; --border: #35332f; --card-border: #35332f;
41
+ --accent: #f3f2ef; --accent-ink: #161615; --accent-soft: rgba(243, 242, 239, 0.12);
42
+ --moment-bg: #463312; --moment-ink: #ffd68a;
43
+ --scope-bg: #133852; --scope-ink: #a3d4fa;
44
+ --mood-bg: #37245b; --mood-ink: #d9c4ff;
45
+ --note-bg: #1e3a20; --note-ink: #b5e3b7;
46
+ --hover: brightness(1.15);
47
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3); color-scheme: dark;
48
+ }
49
+ }
50
+ :root[data-theme="dark"] {
51
+ --bg: #161615; --card: #201f1d; --soft: #2a2826; --field: #1a1918; --ink: #f3f2ef; --muted: #a8a7a1;
52
+ --faint: #6f6d68; --border: #35332f; --card-border: #35332f;
53
+ --accent: #f3f2ef; --accent-ink: #161615; --accent-soft: rgba(243, 242, 239, 0.12);
54
+ --moment-bg: #463312; --moment-ink: #ffd68a;
55
+ --scope-bg: #133852; --scope-ink: #a3d4fa;
56
+ --mood-bg: #37245b; --mood-ink: #d9c4ff;
57
+ --note-bg: #1e3a20; --note-ink: #b5e3b7;
58
+ --hover: brightness(1.15);
59
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3); color-scheme: dark;
60
+ }
61
+
62
+ html { -webkit-text-size-adjust: 100%; }
63
+ body {
64
+ background: var(--bg); color: var(--ink);
65
+ font-family: var(--sans);
66
+ font-size: 14px; line-height: 1.5;
67
+ }
68
+ button, input, textarea, select { font: inherit; color: inherit; }
69
+ button { cursor: pointer; }
70
+ a { color: inherit; }
71
+ code { font-family: var(--mono); font-size: .92em; background: var(--soft); padding: 0 4px; border-radius: 5px; }
72
+
73
+ .app { max-width: 980px; margin: 0 auto; padding: 26px 32px 48px; display: flex; flex-direction: column; gap: 16px; }
74
+
75
+ .hero { margin: 36px 0 8px; }
76
+ .hero-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
77
+ .hero h1 {
78
+ font-size: clamp(30px, 5vw, 46px); font-weight: 800;
79
+ letter-spacing: -0.025em; line-height: 1.08;
80
+ max-width: 640px; margin: 18px 0 10px; color: var(--ink);
81
+ }
82
+ .hero p { font-size: 15px; line-height: 1.55; color: var(--muted); max-width: 640px; }
83
+ .hero p + p { margin-top: 10px; }
84
+ .hero p strong { color: var(--ink); font-weight: 600; }
85
+ .links { display: flex; gap: 8px; flex-wrap: wrap; }
86
+ .links a {
87
+ border: 1px solid var(--border); border-radius: 999px;
88
+ background: var(--card); padding: 6px 14px; line-height: 1.4;
89
+ font-size: 13px; font-weight: 500; color: var(--ink); text-decoration: none;
90
+ }
91
+ .links a:hover { border-color: var(--ink); }
92
+ .spacer { flex: 1; }
93
+ .pill {
94
+ display: inline-flex; align-items: center; gap: 7px; max-width: 100%;
95
+ border: 0; border-radius: 999px; background: var(--card); color: var(--ink);
96
+ font-size: 13px; font-weight: 500; line-height: 1.4; padding: 5px 14px; white-space: nowrap; overflow: hidden;
97
+ }
98
+ .pill b { color: var(--ink); font-weight: 600; }
99
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex-shrink: 0; }
100
+ .dot.ready { background: var(--ok); box-shadow: 0 0 0 3px rgba(31, 157, 85, 0.15); }
101
+ .dot.busy { background: var(--warn); animation: blink 1s infinite; }
102
+ .dot.err { background: var(--err); }
103
+ .account { position: relative; }
104
+ .pill.signin { text-decoration: none; font-weight: 600; }
105
+ .pill.signin:hover { box-shadow: inset 0 0 0 1px var(--border); }
106
+ .msg a { color: inherit; font-weight: 700; margin-left: 6px; }
107
+ .account .pill { cursor: pointer; font-family: inherit; }
108
+ .account .pill:hover { box-shadow: inset 0 0 0 1px var(--border); }
109
+ .menu {
110
+ position: absolute; right: 0; top: calc(100% + 6px); z-index: 40; min-width: 210px;
111
+ background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; padding: 6px;
112
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
113
+ }
114
+ .menu[hidden] { display: none; }
115
+ .menu .sum { padding: 6px 8px 8px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); margin-bottom: 4px; }
116
+ .menu a { display: block; padding: 7px 8px; border-radius: 8px; font-size: 13px; text-decoration: none; }
117
+ .menu a:hover { background: var(--soft); }
118
+ .ibtn {
119
+ width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center;
120
+ border: 0; border-radius: 999px; background: var(--card); color: var(--ink);
121
+ }
122
+ .ibtn:hover { box-shadow: inset 0 0 0 1px var(--border); }
123
+ .ibtn svg { width: 14px; height: 14px; }
124
+
125
+ .card { background: var(--card); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px; }
126
+ .card-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding: 0 2px; }
127
+ .card-head h2 { font-size: 14px; font-weight: 600; color: var(--ink); }
128
+ .card-head .spacer { flex: 1; }
129
+ .linkbtn {
130
+ border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--muted);
131
+ padding: 3px 11px; font-size: 12.5px; font-weight: 500; transition: color .12s ease, border-color .12s ease;
132
+ }
133
+ .linkbtn:hover { color: var(--ink); border-color: var(--ink); }
134
+
135
+ .boot { display: none; border-color: var(--border); background: var(--card); }
136
+ .boot.show { display: block; }
137
+ .boot h3 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 2px; }
138
+ .boot p { color: var(--muted); font-size: 13px; }
139
+ .boot .log { margin-top: 8px; font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
140
+ .boot .bar { height: 3px; border-radius: 2px; background: var(--soft); overflow: hidden; margin-top: 10px; }
141
+ .boot .bar i { display: block; height: 100%; width: 30%; background: var(--ink); border-radius: 2px; animation: sweep 1.6s ease-in-out infinite; }
142
+ .boot.error { border-color: var(--err); }
143
+ .boot.error .bar { display: none; }
144
+
145
+ .editor {
146
+ position: relative; border: 1px solid var(--border); border-radius: 12px;
147
+ background: var(--field); transition: border-color .12s ease;
148
+ }
149
+ .editor:focus-within { border-color: var(--muted); }
150
+ .mirror, textarea.script {
151
+ font-family: var(--sans); font-size: 16px; line-height: 1.6; letter-spacing: normal; font-weight: 400;
152
+ padding: 12px 14px; margin: 0; border: 0;
153
+ white-space: pre-wrap; overflow-wrap: break-word; word-break: normal;
154
+ tab-size: 4; box-sizing: border-box;
155
+ }
156
+ .mirror { min-height: 120px; color: var(--ink); pointer-events: none; }
157
+ textarea.script {
158
+ position: absolute; inset: 0; width: 100%; height: 100%;
159
+ resize: none; overflow: hidden; background: transparent;
160
+ color: transparent; caret-color: var(--ink); outline: none;
161
+ }
162
+ textarea.script::placeholder { color: var(--muted); opacity: .75; }
163
+ textarea.script::selection { background: rgba(120, 120, 255, .28); color: transparent; }
164
+ .mirror mark { color: inherit; border-radius: 4px; padding: 0; margin: 0; }
165
+ .mirror .h-point { background: var(--moment-bg); color: var(--moment-ink); }
166
+ .mirror .h-tag { background: var(--scope-bg); color: var(--scope-ink); }
167
+ .mirror .h-span { text-decoration: underline; text-decoration-color: var(--scope-ink); text-decoration-thickness: 2px; text-underline-offset: 4px; }
168
+ .mirror .h-style { background: var(--mood-bg); color: var(--mood-ink); }
169
+ .mirror .h-note { background: var(--note-bg); color: var(--note-ink); }
170
+ .field:focus { border-color: var(--muted); }
171
+ .script-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 6px; padding: 0 2px; font-size: 12px; color: var(--muted); gap: 8px; }
172
+ #charCount { font-variant-numeric: tabular-nums; white-space: nowrap; }
173
+ .palette { display: grid; grid-template-columns: minmax(0, 1fr); gap: 7px; margin-top: 12px; padding: 0 2px; }
174
+ .prow { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
175
+ .plabel {
176
+ display: inline-flex; align-items: center; gap: 5px; height: 25px; width: 72px; flex-shrink: 0;
177
+ font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted);
178
+ }
179
+ .plabel i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex: none; }
180
+ .prow > .prow { flex: 1; min-width: 0; }
181
+ .chip {
182
+ display: inline-flex; align-items: center; gap: 4px; height: 27px; box-sizing: border-box;
183
+ border: 1px solid var(--border); background: var(--card); border-radius: 999px;
184
+ padding: 0 11px; font-size: 12.5px; font-weight: 500; line-height: 1; white-space: nowrap;
185
+ transition: border-color .12s ease, background .12s ease, filter .12s ease;
186
+ }
187
+ .chip:hover { border-color: var(--ink); }
188
+ .chip.on { background: var(--ink); color: var(--bg); border-color: var(--ink); }
189
+ .chip.dashed { border-style: dashed; background: transparent; color: var(--muted); }
190
+ .chip.dashed:hover { color: var(--ink); border-color: var(--muted); }
191
+ .chip:disabled { opacity: .5; cursor: default; }
192
+ .palette .chip { height: 25px; padding: 0 9px; border-color: transparent; }
193
+ .palette .chip:hover { filter: var(--hover); border-color: transparent; }
194
+ .prow[data-kind=point] .plabel i { background: var(--moment-ink); }
195
+ .prow[data-kind=scope] .plabel i { background: var(--scope-ink); }
196
+ .prow[data-kind=style] .plabel i { background: var(--mood-ink); }
197
+ .prow[data-kind=point] .chip { background: var(--moment-bg); color: var(--moment-ink); }
198
+ .prow[data-kind=scope] .chip { background: var(--scope-bg); color: var(--scope-ink); }
199
+ .prow[data-kind=style] .chip { background: var(--mood-bg); color: var(--mood-ink); }
200
+ .examples { display: flex; gap: 6px; overflow-x: auto; padding: 0 2px 2px; scrollbar-width: none; }
201
+ .examples::-webkit-scrollbar { display: none; }
202
+
203
+ .seg { display: flex; background: var(--soft); border-radius: 999px; padding: 3px; gap: 3px; margin-bottom: 12px; }
204
+ .seg button { flex: 1; border: none; background: none; border-radius: 999px; padding: 7px 6px; font-weight: 600; font-size: 13px; color: var(--muted); }
205
+ .seg button.on { background: var(--card); color: var(--ink); box-shadow: 0 1px 2px rgba(20, 20, 20, 0.08); }
206
+ .pane { display: none; flex-direction: column; gap: 10px; }
207
+ .pane.on { display: flex; }
208
+ .hint { font-size: 12.5px; color: var(--muted); }
209
+ .hint b { color: var(--ink); font-weight: 600; }
210
+ .voices { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
211
+ .rec { display: inline-flex; align-items: center; gap: 6px; }
212
+ .rec.live .chip { border-color: var(--err); color: var(--err); }
213
+ .meter { width: 70px; height: 4px; background: var(--soft); border-radius: 2px; overflow: hidden; display: none; }
214
+ .rec.live .meter { display: block; }
215
+ .meter i { display: block; height: 100%; width: 0; background: var(--err); }
216
+ .preview { width: 100%; height: 34px; }
217
+ label.fl { display: block; font-size: 13px; font-weight: 600; margin-bottom: 5px; }
218
+ label.fl span { font-weight: 400; color: var(--muted); }
219
+ .field {
220
+ width: 100%; border: 1px solid var(--border); border-radius: 12px; background: var(--field);
221
+ padding: 9px 12px; font-size: 14px; outline: none; resize: vertical; transition: border-color .12s ease;
222
+ }
223
+ .row2 { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; }
224
+ .badge { display: inline-block; font-size: 10.5px; font-weight: 600; letter-spacing: .03em; padding: 1px 7px; border-radius: 999px; background: var(--scope-bg); color: var(--scope-ink); vertical-align: 1px; }
225
+
226
+ .perform-row { display: flex; gap: 8px; align-items: center; }
227
+ .perform {
228
+ flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 46px;
229
+ border: none; border-radius: 999px; background: var(--ink); color: var(--bg);
230
+ padding: 12px 20px; font-size: 15px; font-weight: 600; transition: transform .08s, box-shadow .2s, opacity .12s;
231
+ }
232
+ .perform:hover:not(:disabled) { box-shadow: 0 0 0 4px var(--accent-soft); }
233
+ .perform:active:not(:disabled) { transform: scale(0.99); }
234
+ .perform:disabled { opacity: .45; cursor: default; }
235
+ .perform.live:disabled { opacity: 1; }
236
+ .perform.live { background: var(--ink); color: var(--bg); box-shadow: 0 0 0 4px var(--accent-soft); }
237
+ .perform kbd { font-family: var(--mono); font-size: 11px; opacity: .55; font-weight: 500; }
238
+ .onair { width: 8px; height: 8px; border-radius: 50%; background: var(--err); display: none; animation: blink .9s infinite; }
239
+ .perform.live .onair { display: inline-block; }
240
+ .stop { border: 1px solid var(--border); background: var(--card); border-radius: 999px; min-height: 46px; padding: 0 18px; font-weight: 600; display: none; }
241
+ .stop:hover { border-color: var(--ink); }
242
+ .stop.show { display: inline-block; }
243
+
244
+ .msg { display: none; padding: 9px 12px; border-radius: 12px; font-size: 13px; }
245
+ .msg.show { display: block; }
246
+ .msg.err { background: rgba(214, 69, 69, 0.08); color: var(--err); border: 1px solid rgba(214, 69, 69, 0.25); }
247
+ .msg.info { background: var(--card); color: var(--muted); border: 1px solid var(--card-border); }
248
+
249
+ .stage { display: none; }
250
+ .stage.show { display: block; }
251
+ canvas.viz { width: 100%; height: 76px; display: block; border-radius: 12px; background: var(--field); border: 1px solid var(--border); }
252
+ .metrics { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 10px; font-size: 12px; color: var(--muted); }
253
+ .metrics b { font-family: var(--mono); color: var(--ink); font-weight: 600; }
254
+ .metrics .hot b { color: var(--ink); }
255
+ .take-tools { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 10px; }
256
+ .take-tools audio { flex: 1 1 220px; height: 34px; min-width: 0; }
257
+ .direct { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--card-border); }
258
+ .direct h3 { font-size: 14px; font-weight: 600; margin-bottom: 2px; }
259
+ .direct .hint { margin-bottom: 8px; }
260
+ .note-row { display: flex; gap: 6px; margin-top: 8px; }
261
+ .note-row .field { flex: 1; min-width: 0; border-radius: 999px; padding: 8px 14px; }
262
+ .btn {
263
+ border: 1px solid var(--ink); background: var(--ink); color: var(--bg); border-radius: 999px;
264
+ padding: 8px 16px; font-weight: 600; font-size: 13px; white-space: nowrap;
265
+ }
266
+ .btn.ghost { background: var(--card); color: var(--ink); border-color: var(--border); }
267
+ .btn.ghost:hover { border-color: var(--ink); }
268
+ .btn:disabled { opacity: .45; cursor: default; }
269
+
270
+ .slate { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
271
+ .slate-item {
272
+ display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px;
273
+ padding: 8px 10px; border: 1px solid var(--border); border-radius: 12px; background: var(--field);
274
+ }
275
+ .slate-item.current { border-color: var(--ink); }
276
+ .slate-num { font-family: var(--mono); font-size: 11px; color: var(--faint); }
277
+ .slate-body { min-width: 0; }
278
+ .slate-title { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
279
+ .slate-sub { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
280
+ .slate-actions { display: flex; gap: 4px; }
281
+ .mini { border: 1px solid var(--border); background: var(--card); border-radius: 999px; padding: 3px 10px; font-size: 12px; font-weight: 500; }
282
+ .mini:hover { border-color: var(--ink); }
283
+
284
+ .sheet-bg { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none; align-items: center; justify-content: center; padding: 16px; z-index: 50; }
285
+ .sheet-bg.open { display: flex; }
286
+ .sheet { background: var(--card); border: 1px solid var(--card-border); border-radius: 18px; width: 100%; max-width: 560px; max-height: 86vh; overflow: auto; padding: 18px; }
287
+ .sheet h3 { font-size: 16px; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 4px; }
288
+ .sheet > p { color: var(--muted); font-size: 13px; margin-bottom: 12px; }
289
+ .cheat { width: 100%; border-collapse: collapse; font-size: 13px; }
290
+ .cheat td { padding: 8px 6px; border-top: 1px solid var(--card-border); vertical-align: top; }
291
+ .cheat td:first-child { font-weight: 600; white-space: nowrap; }
292
+ .cheat code { font-size: 12px; padding: 1px 5px; }
293
+ .srow { display: grid; grid-template-columns: 110px 1fr 44px; gap: 10px; align-items: center; margin: 10px 0; font-size: 13px; }
294
+ .srow input[type=range] { width: 100%; accent-color: var(--ink); }
295
+ .srow output { font-family: var(--mono); font-size: 12px; text-align: right; color: var(--muted); }
296
+
297
+ details.credits { color: var(--muted); font-size: 13px; }
298
+ details.credits summary { cursor: pointer; color: var(--ink); font-weight: 600; font-size: 14px; }
299
+ details.credits p { margin-top: 8px; }
300
+ .fine { color: var(--muted); font-size: 12px; }
301
+
302
+ @media (max-width: 560px) {
303
+ .row2 { grid-template-columns: 1fr; }
304
+ .plabel { width: 100%; }
305
+ .palette .prow > .prow { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; }
306
+ .palette .prow > .prow::-webkit-scrollbar { display: none; }
307
+ .app { padding: 10px 16px 48px; }
308
+ .hero { margin-top: 20px; }
309
+ .perform kbd { display: none; }
310
+ .srow { grid-template-columns: 90px 1fr 40px; }
311
+ }
312
+
313
+ @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
314
+ @keyframes sweep { 0% { transform: translateX(-110%); } 100% { transform: translateX(360%); } }
315
+
316
+ /* Keep the resources in the introduction, before the studio. */
317
+ .resource-links { gap: 8px; margin: 0 0 6px; }
318
+ .resource-links a { display: inline-flex; align-items: center; min-height: 40px; padding: 9px 16px; background: var(--card); font-size: 14px; font-weight: 600; }
319
+ .credits { font-size: 13px; }
320
+ .credits h2 { margin: 0 0 10px; color: var(--ink); font-size: 16px; font-weight: 650; }
321
+ @media (max-width: 640px) {
322
+ .resource-links { gap: 7px; }
323
+ .resource-links a { min-height: 40px; padding: 8px 12px; font-size: 13px; }
324
+ }
325
+
326
+ .direction-dropdown { margin-top: 12px; padding: 12px; border: 1px solid var(--border); border-radius: 12px; }
327
+ .direction-dropdown > summary { cursor: pointer; font-size: 13px; font-weight: 600; }
328
+
329
+ </style>
330
+ </head>
331
+ <body>
332
+ <div class="app">
333
+
334
+ <header class="hero">
335
+ <div class="hero-top">
336
+ <span class="pill" id="statusPill"><span class="dot" id="statusDot"></span><span id="statusText">connecting…</span></span>
337
+ <span class="spacer"></span>
338
+ <a class="pill signin" id="signIn" href="/oauth/huggingface/login" target="_blank" rel="noopener" hidden></a>
339
+ <div class="account" id="account" hidden>
340
+ <button class="pill" id="userPill" type="button" aria-haspopup="true" aria-expanded="false" onclick="toggleAccount(event)"></button>
341
+ <div class="menu" id="accountMenu" hidden>
342
+ <div class="sum" id="accountSummary"></div>
343
+ <a href="https://huggingface.co/pricing" target="_blank" rel="noreferrer">Unlimited takes with PRO</a>
344
+ <a href="/oauth/huggingface/logout">Sign out</a>
345
+ </div>
346
+ </div>
347
+ <button class="ibtn" type="button" onclick="openSheet('settingsSheet')" title="Sampling settings" aria-label="Sampling settings">
348
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
349
+ </button>
350
+ <button class="ibtn" type="button" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">
351
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
352
+ </button>
353
+ </div>
354
+ <h1>🎭 Drama 3</h1>
355
+ <p>by Fish Audio · served with SGLang Omni</p>
356
+ <p>Write a line, pick a voice, and <strong>direct the performance</strong>: drop in a <code>[laughs]</code>, whisper a phrase, or describe a voice that doesn't exist yet. Then give the actor a note and get another take. The 35B MoE streams from a single GPU as it generates.</p>
357
+ <nav class="links resource-links" aria-label="Drama 3 resources">
358
+ <a href="https://sgl-project.github.io/sglang-omni/cookbook/fishaudio_drama3.html" target="_blank" rel="noreferrer">SGLang Omni cookbook</a>
359
+ <a href="https://github.com/sgl-project/sglang-omni" target="_blank" rel="noreferrer">⭐ Star SGLang Omni</a>
360
+ <a href="https://github.com/fishaudio/fish-speech/tree/main/docs/assets/Fish_Audio_Drama_3_Technical_Report.pdf" target="_blank" rel="noreferrer">Technical report</a>
361
+ <a href="https://fish.audio" target="_blank" rel="noreferrer">Fish Audio</a>
362
+ <a href="https://github.com/fishaudio/fish-speech" target="_blank" rel="noreferrer">Fish Speech</a>
363
+ <a href="https://huggingface.co/fishaudio/drama-3" target="_blank" rel="noreferrer">Model card</a>
364
+ </nav>
365
+ </header>
366
+
367
+ <section class="card boot" id="boot">
368
+ <h3 id="bootTitle">Setting the stage…</h3>
369
+ <p id="bootPhase">Launching SGLang Omni</p>
370
+ <div class="bar"><i></i></div>
371
+ <div class="log" id="bootLog"></div>
372
+ </section>
373
+
374
+ <!-- Script -->
375
+ <section class="card">
376
+ <div class="card-head">
377
+ <h2>Script</h2>
378
+ <span class="spacer"></span>
379
+ <button class="linkbtn" type="button" onclick="openSheet('helpSheet')">How to direct</button>
380
+ </div>
381
+ <div class="examples" id="examples"></div>
382
+ <div class="editor" style="margin-top:10px"><div class="mirror" id="mirror" aria-hidden="true"></div><textarea class="script" id="script" spellcheck="true" aria-label="Script"></textarea></div>
383
+ <details class="direction-dropdown">
384
+ <summary>Direction tags</summary>
385
+ <div class="script-meta"><span>Tags in <code>[ ]</code> and <code>&lt; &gt;</code> are direction, not words. Any wording works.</span><span id="charCount">0</span></div>
386
+ <div class="palette">
387
+ <div class="prow" data-kind="point"><span class="plabel"><i></i>Events</span><span id="pointTags" class="prow"></span></div>
388
+ <div class="prow" data-kind="scope"><span class="plabel"><i></i>Span</span><span id="scopedTags" class="prow"></span></div>
389
+ <div class="prow" data-kind="style"><span class="plabel"><i></i>Mood</span><span id="styleTags" class="prow"></span></div>
390
+ </div>
391
+ </details>
392
+ </section>
393
+
394
+ <!-- Voice -->
395
+ <section class="card">
396
+ <div class="card-head"><h2>Voice</h2></div>
397
+ <div class="seg" role="tablist">
398
+ <button type="button" data-mode="random" onclick="setMode('random')">🎲 Surprise</button>
399
+ <button type="button" data-mode="clone" onclick="setMode('clone')">🎙 Clone</button>
400
+ <button type="button" data-mode="design" onclick="setMode('design')">✨ Design</button>
401
+ </div>
402
+
403
+ <div class="pane" id="pane-clone">
404
+ <div class="voices" id="voiceChips"></div>
405
+ <audio class="preview" id="voicePreview" controls preload="none"></audio>
406
+ <div>
407
+ <label class="fl" for="transcript">What the clip says <span id="transcriptState"></span></label>
408
+ <textarea class="field" id="transcript" rows="2" placeholder="Exact words spoken in the clip"></textarea>
409
+ </div>
410
+ <p class="hint">Upload or record a few seconds of one speaker. Clips are trimmed to 20 s and transcribed for you. Only clone voices you have permission to use.</p>
411
+ </div>
412
+
413
+ <div class="pane" id="pane-design">
414
+ <div class="voices" id="castChips"></div>
415
+ <div class="row2">
416
+ <div><label class="fl" for="charName">Character</label><input class="field" id="charName" maxlength="40" placeholder="Captain"></div>
417
+ <div><label class="fl" for="charDesc">Voice description</label><textarea class="field" id="charDesc" rows="2" maxlength="300" placeholder="gravelly old male voice, about seventy, weathered and slow"></textarea></div>
418
+ </div>
419
+ <div><label class="fl" for="charScene">Scene <span>(optional)</span></label><input class="field" id="charScene" maxlength="200" placeholder="a storm-battered tavern at the edge of the harbor"></div>
420
+ <p class="hint">No audio needed: Drama 3 casts the voice from your description, the way it voices characters in a scene. Describe age, timbre, accent, and energy. Like a take? <b>Keep this voice</b> turns it into a clone.</p>
421
+ </div>
422
+
423
+ <div class="pane" id="pane-random">
424
+ <p class="hint">No reference and no description: Drama 3 improvises a voice that fits the line. Every take is a stranger, so hit <b>Keep this voice</b> when you meet one you like.</p>
425
+ </div>
426
+ </section>
427
+
428
+ <div class="perform-row">
429
+ <button class="perform" id="performBtn" type="button" onclick="perform()" disabled>
430
+ <span class="onair"></span><span id="performLabel">Warming up…</span><kbd>⌘↵</kbd>
431
+ </button>
432
+ <button class="stop" id="stopBtn" type="button" onclick="stopTake()">Stop</button>
433
+ </div>
434
+ <div class="msg" id="msg"></div>
435
+
436
+ <!-- Stage -->
437
+ <section class="card stage" id="stage">
438
+ <div class="card-head"><h2 id="takeTitle">Take 1</h2><span class="spacer"></span><span class="hint" id="takeVoice"></span></div>
439
+ <canvas class="viz" id="viz"></canvas>
440
+ <div class="metrics" id="metrics"></div>
441
+ <div class="take-tools">
442
+ <audio id="takeAudio" controls preload="auto"></audio>
443
+ <button class="btn ghost" type="button" id="downloadBtn" onclick="downloadTake()" disabled>Download</button>
444
+ <button class="btn ghost" type="button" id="keepBtn" onclick="keepVoice()" disabled title="Use this take as a clone reference">Keep this voice</button>
445
+ </div>
446
+ <div class="direct" id="direct">
447
+ <h3>🎬 Give a note</h3>
448
+ <p class="hint">The actor hears this take, then redoes the line with your note, like the multi-turn directing in the Drama 3 paper.</p>
449
+ <div class="voices" id="noteChips"></div>
450
+ <div class="note-row">
451
+ <input class="field" id="noteIn" maxlength="120" placeholder="e.g. more amused than before; drop the hurt">
452
+ <button class="btn" type="button" id="retakeBtn" onclick="retake()">Retake</button>
453
+ </div>
454
+ </div>
455
+ <div class="slate" id="slate"></div>
456
+ </section>
457
+
458
+ <!-- Acknowledgments are always visible. -->
459
+ <section class="card">
460
+
461
+ <section class="credits" aria-labelledby="acknowledgments-title">
462
+ <h2 id="acknowledgments-title">Acknowledgments</h2>
463
+ <p>It took us over half a year to bring SGLang Omni back to life. Thanks to all the people who helped us along the way.</p>
464
+ <p>Thanks so much to <a href="https://huggingface.co/andito" target="_blank" rel="noreferrer">andito</a>, <a href="https://x.com/_akhaliq" target="_blank" rel="noreferrer">AK</a>, and <a href="https://huggingface.co/multimodalart" target="_blank" rel="noreferrer">poli</a> for helping set up the Space.</p>
465
+ <p><b>SGLang Omni team:</b> <a href="https://x.com/GenAI_is_real" target="_blank" rel="noreferrer">Chenyang Zhao</a>, Jingwen Gu, Shenggui Li, Richard Wang, Yichi Zhang, Xuesong Ye, Jiaxin Deng, Ratish P, Jiaxuan Luo, Xinyu Lu, Wenyao Gao, Ao Sun, Jintao Quaige Li, Yuhao Chen, Yuhan Zhou, Xuxiang Tian, Ruoyi Zhang, Junnan Li, Yixiao Zeng, Chenchen Hong, Huapeng Zhou, Yang Gao, Chaoye Fei, Yiyang Zh, Kang, Yitian Gong, Yuan Luo, Hao Jin, Yifei Gao, Gaokai Zhang, Lifan Shen, Hui Shen, Kejing Xia, Jinxin Li, Dongming Shen, Jun Liu, Yue Yin, Zhihao Guo, Jisen Li, Ruoyu He, Xuanyi Li, Chenye Zhu, Liang Zhen, Xiaodong Ye, Yizhi Zhen, Kaizuo Li, Chonghan Liu, Wei Guan, Zhuofeng Li</p>
466
+ <p><b>FishAudio Team:</b> Shijia Liao, Shidong Li, Xuehao Yang, Jiahua Liu</p>
467
+ </section>
468
+ <p class="fine" style="margin-top:10px">Model license: <a href="https://huggingface.co/fishaudio/drama-3/blob/main/LICENSE" target="_blank" rel="noreferrer">Fish Audio Community License</a>, for research and non-commercial use. Takes and uploaded voices live in server memory only and expire.</p>
469
+ </section>
470
+ </div>
471
+
472
+ <!-- Help -->
473
+ <div class="sheet-bg" id="helpSheet" onclick="closeSheet(event)">
474
+ <div class="sheet" role="dialog" aria-label="How to direct">
475
+ <h3>How to direct Drama 3</h3>
476
+ <p>Direction is plain language inside the script. Brackets and angle tags are never spoken; round parentheses are. These are the controls from the technical report (Appendix C); the wording is yours.</p>
477
+ <table class="cheat">
478
+ <tr><td>Point tag</td><td>An event at that spot.<br><code>Well [sighs] fine, you win. [laughs]</code></td></tr>
479
+ <tr><td>Scoped tag</td><td>Changes only the words inside.<br><code>Promise me &lt;whisper&gt;you won't tell her&lt;/whisper&gt;.</code></td></tr>
480
+ <tr><td>Style prefix</td><td>Sets the delivery of the whole line.<br><code>[icy calm, as if you already accepted it] Fine.</code></td></tr>
481
+ <tr><td>Director's note</td><td>Intent, pacing, emphasis, on two lines.<br><code>Direction: put the rise on "goodbye".<br>Line: So this is goodbye.</code></td></tr>
482
+ <tr><td>Retake note</td><td>After a take, a note relative to it.<br><code>[softer and warmer versus previous]</code></td></tr>
483
+ <tr><td>Character</td><td>In ✨ Design, a description casts the voice.<br><code>Emma: young female voice, sleepy, half talking to herself.</code></td></tr>
484
+ </table>
485
+ <div style="margin-top:14px;text-align:right"><button class="btn" type="button" onclick="closeSheet()">Got it</button></div>
486
+ </div>
487
+ </div>
488
+
489
+ <!-- Settings -->
490
+ <div class="sheet-bg" id="settingsSheet" onclick="closeSheet(event)">
491
+ <div class="sheet" role="dialog" aria-label="Sampling settings">
492
+ <h3>Sampling</h3>
493
+ <p>Defaults: temperature 1.0, top-p 0.9, and no top-k cap. A low top-k makes takes go silent mid-line, so 0 (no cap) is recommended.</p>
494
+ <div class="srow"><label for="temp">Temperature</label><input type="range" id="temp" min="0.3" max="1.3" step="0.05"><output id="tempV"></output></div>
495
+ <div class="srow"><label for="topp">Top-p</label><input type="range" id="topp" min="0.5" max="1" step="0.01"><output id="toppV"></output></div>
496
+ <div class="srow"><label for="topk">Top-k</label><input type="range" id="topk" min="0" max="100" step="1"><output id="topkV"></output></div>
497
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px">
498
+ <button class="btn ghost" type="button" onclick="resetSampling()">Reset</button>
499
+ <button class="btn" type="button" onclick="closeSheet()">Done</button>
500
+ </div>
501
+ </div>
502
+ </div>
503
+
504
+ <input type="file" id="fileIn" accept="audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm" hidden>
505
+
506
+ <script>
507
+ // ── Content ─────────────────────────────────────────────────────────────────
508
+ const EXAMPLES = [
509
+ {
510
+ "label": "Stage fright",
511
+ "text": "[clears throat] Ladies and gentlemen... [hesitates] I had a speech. [nervous laugh] I really did have a speech."
512
+ },
513
+ {
514
+ "label": "We won!",
515
+ "text": "[with bright eager excitement] We won! We actually won! [laughs] I can't believe it!"
516
+ },
517
+ {
518
+ "label": "Missed train",
519
+ "text": "[sighs] Fine. If we miss it, it's on you. [laughs] Well, maybe that's fair."
520
+ },
521
+ {
522
+ "label": "A secret",
523
+ "text": "Promise me <whisper>you won't tell her</whisper>. [pause] Not a word. Not even a hint."
524
+ },
525
+ {
526
+ "label": "Bad news",
527
+ "text": "[with heavy, deflated sadness] The results just came in. We didn't make it.",
528
+ "note": "more amused than before; drop the hurt"
529
+ },
530
+ {
531
+ "label": "Goodbye",
532
+ "text": "Direction: icy calm, as if you already accepted it. Put the rise on \"goodbye\".\nLine: So this is goodbye."
533
+ },
534
+ {
535
+ "label": "Commentator",
536
+ "text": "[breathless, rising excitement] He's past one... past two... he shoots, and [shouting] HE SCORES! Unbelievable!"
537
+ },
538
+ {
539
+ "label": "Museum guard",
540
+ "text": "Direction: deadpan, a bored museum guard at the end of a very long shift.\nLine: Please do not touch the dinosaur. Again."
541
+ }
542
+ ];
543
+ const CAST = [
544
+ { label: '🏴‍☠️ Sea captain', name: 'Captain', desc: 'gravelly old male voice, about seventy, weathered and slow, salt in every word',
545
+ scene: 'a storm-battered tavern at the edge of the harbor', text: "[low and menacing] Ye want to know what's out past the reef? [pause] Pray ye never find out." },
546
+ { label: '📻 Night radio', name: 'Night Owl', desc: 'smooth warm baritone, a late-night radio host, relaxed and intimate, close to the mic',
547
+ scene: '', text: "It's two in the morning, the city's finally asleep... [chuckles] and you're still here with me. Good." },
548
+ { label: '🧒 Hyper kid', name: 'Mia', desc: 'excited little girl, about seven, talks fast, giggly and breathless',
549
+ scene: '', text: '[gasps] Did you see that? The dog just ate a WHOLE pizza! [laughs] Can we get a dog? Please please please?' },
550
+ { label: '🕵️ Noir detective', name: 'Detective', desc: 'world-weary man in his forties, low smoky voice, clipped and dry',
551
+ scene: 'a rain-soaked office, one desk lamp still on', text: 'She walked in at midnight. [pause] Trouble usually knocks. This one had a key.' },
552
+ { label: '📯 Royal herald', name: 'Herald', desc: 'booming, pompous royal herald, theatrical and grandly formal',
553
+ scene: '', text: '[clears throat] Hear ye, hear ye! By order of the Queen, all cheese is hereby... [hesitates] mandatory.' },
554
+ { label: '😴 Sleepy intern', name: 'Emma', desc: 'young female voice, sleepy, half talking to herself',
555
+ scene: 'a small office on a Monday morning; the weekly meeting is about to start', text: '[softly][to herself] Here we go again... [sighs] Is it Friday yet?' },
556
+ { label: '🦹 Villain', name: 'The Duke', desc: 'silky, theatrical villain with a rich low voice, amused and unhurried',
557
+ scene: '', text: '<slow>How delightful</slow>. You actually thought the plan was yours. [laughs]' },
558
+ { label: '👵 Nana', name: 'Nana', desc: 'warm elderly woman, soft and slightly shaky, a gentle storyteller',
559
+ scene: 'a quiet bedroom, the lights already dimmed', text: '[warm, gentle bedtime voice] Once upon a time, in a kingdom made entirely of cheese, there lived a very worried mouse.' },
560
+ ];
561
+ const POINT_TAGS = ['laughs', 'chuckles', 'sighs', 'gasps', 'pause', 'clears throat', 'hesitates'];
562
+ const SCOPED_TAGS = ['whisper', 'slow', 'emphasis'];
563
+ const STYLES = ['with bright eager excitement', 'icy calm', 'heavy, deflated sadness', 'sarcastic and deadpan',
564
+ 'breathless and terrified', 'furious, barely holding it together', 'warm, gentle bedtime voice'];
565
+ const NOTES = ['softer and warmer', 'more excited', 'more amused than before; drop the hurt', 'slower, with more pauses',
566
+ 'angrier', 'whispered', 'faster', 'more sarcastic'];
567
+ // top_k 0 is no cap; settings saved before it became the default are not reused.
568
+ const DEFAULT_SAMPLING = { temperature: 1.0, top_p: 0.9, top_k: 0 };
569
+ const SAMPLING_KEY = 'sampling-v2';
570
+
571
+ // ── Helpers ─────────────────────────────────────────────────────────────────
572
+ const $ = id => document.getElementById(id);
573
+ const saved = {
574
+ get(key, fallback) { try { const v = localStorage.getItem('drama3:' + key); return v == null ? fallback : JSON.parse(v); } catch { return fallback; } },
575
+ set(key, value) { try { localStorage.setItem('drama3:' + key, JSON.stringify(value)); } catch {} },
576
+ };
577
+ function el(tag, props = {}, text) {
578
+ const node = document.createElement(tag);
579
+ Object.assign(node, props);
580
+ if (text != null) node.textContent = text;
581
+ return node;
582
+ }
583
+ function showMsg(kind, text, action) {
584
+ const m = $('msg');
585
+ m.className = 'msg show ' + kind;
586
+ m.textContent = text;
587
+ if (action) m.append(el('a', { href: action.href, target: '_blank', rel: 'noopener' }, action.label));
588
+ }
589
+ function hideMsg() { $('msg').className = 'msg'; }
590
+ function fmtTime(s) { s = Math.max(0, Math.round(s)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }
591
+
592
+ // ── State ───────────────────────────────────────────────────────────────────
593
+ const state = {
594
+ mode: saved.get('mode', 'random'),
595
+ voiceId: saved.get('voiceId', 'clone1'),
596
+ presets: [],
597
+ custom: [],
598
+ engineReady: false,
599
+ transcriber: 'waiting',
600
+ busy: false,
601
+ controller: null,
602
+ takes: [],
603
+ current: null,
604
+ sampling: { ...DEFAULT_SAMPLING, ...saved.get(SAMPLING_KEY, {}) },
605
+ };
606
+
607
+ // ── Theme ───────────────────────────────────────────────────────────────────
608
+ function applyTheme(theme) { if (theme) document.documentElement.dataset.theme = theme; }
609
+ function toggleTheme() {
610
+ const dark = document.documentElement.dataset.theme
611
+ ? document.documentElement.dataset.theme === 'dark'
612
+ : matchMedia('(prefers-color-scheme: dark)').matches;
613
+ const next = dark ? 'light' : 'dark';
614
+ applyTheme(next); saved.set('theme', next);
615
+ }
616
+ applyTheme(saved.get('theme', null));
617
+
618
+ // ── Sheets ──────────────────────────────────────────────────────────────────
619
+ function openSheet(id) { $(id).classList.add('open'); }
620
+ function closeSheet(event) {
621
+ if (event && !event.target.classList.contains('sheet-bg')) return;
622
+ document.querySelectorAll('.sheet-bg.open').forEach(s => s.classList.remove('open'));
623
+ }
624
+
625
+ // ── Script + palette ────────────────────────────────────────────────────────
626
+ function isMood(inner) {
627
+ const t = inner.trim().toLowerCase();
628
+ return STYLES.some(x => x.toLowerCase() === t) || t.split(/\s+/).length >= 3;
629
+ }
630
+ function esc(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
631
+ function render() {
632
+ let h = esc($('script').value);
633
+ h = h.replace(/(^|\n)([ \t]*)(Direction:|Line:)/g, '$1$2<mark class="h-note">$3</mark>');
634
+ h = h.replace(/&lt;([A-Za-z][\w-]*)&gt;([\s\S]*?)&lt;\/\1&gt;/g, (_, name, body) =>
635
+ `<mark class="h-tag">&lt;${name}&gt;</mark><span class="h-span">${body}</span><mark class="h-tag">&lt;/${name}&gt;</mark>`);
636
+ h = h.replace(/\[([^\[\]\n]*)\]/g, (m, inner, offset, str) => {
637
+ const atStart = /^[ \t]*$/.test(str.slice(str.lastIndexOf('\n', offset - 1) + 1, offset));
638
+ return `<mark class="${atStart && isMood(inner) ? 'h-style' : 'h-point'}">[${inner}]</mark>`;
639
+ });
640
+ $('mirror').innerHTML = h + '\n\u200b';
641
+ }
642
+ function updateCount() {
643
+ render();
644
+ const n = $('script').value.length;
645
+ $('charCount').textContent = `${n} / ${state.maxChars || 600}`;
646
+ $('charCount').style.color = n > (state.maxChars || 600) ? 'var(--err)' : '';
647
+ saved.set('script', $('script').value);
648
+ }
649
+ function insertAtCursor(snippet) {
650
+ const ta = $('script');
651
+ const start = ta.selectionStart, end = ta.selectionEnd, v = ta.value;
652
+ const before = v.slice(0, start), after = v.slice(end);
653
+ const padL = before && !/\s$/.test(before) ? ' ' : '';
654
+ const padR = after && !/^\s/.test(after) ? ' ' : '';
655
+ ta.value = before + padL + snippet + padR + after;
656
+ const caret = (before + padL + snippet + padR).length;
657
+ ta.focus(); ta.setSelectionRange(caret, caret); updateCount();
658
+ }
659
+ function wrapSelection(tag) {
660
+ const ta = $('script');
661
+ const start = ta.selectionStart, end = ta.selectionEnd, v = ta.value;
662
+ const inner = v.slice(start, end) || 'these words';
663
+ ta.value = v.slice(0, start) + `<${tag}>${inner}</${tag}>` + v.slice(end);
664
+ const s = start + tag.length + 2;
665
+ ta.focus(); ta.setSelectionRange(s, s + inner.length); updateCount();
666
+ }
667
+ function setStyle(style) {
668
+ const ta = $('script');
669
+ const v = ta.value.replace(/^\s*\[[^\[\]\n]*\]\s*/, '');
670
+ ta.value = `[${style}] ` + v;
671
+ ta.focus(); updateCount();
672
+ }
673
+ function renderPalette() {
674
+ POINT_TAGS.forEach(t => $('pointTags').append(el('button', { type: 'button', className: 'chip tag', onclick: () => insertAtCursor(`[${t}]`) }, `[${t}]`)));
675
+ SCOPED_TAGS.forEach(t => $('scopedTags').append(el('button', { type: 'button', className: 'chip tag', title: 'Wraps the selected words', onclick: () => wrapSelection(t) }, `<${t}>…</${t}>`)));
676
+ STYLES.forEach(t => $('styleTags').append(el('button', { type: 'button', className: 'chip', title: 'Sets the delivery of the whole line', onclick: () => setStyle(t) }, t)));
677
+ EXAMPLES.forEach(x => $('examples').append(el('button', { type: 'button', className: 'chip dashed', onclick: () => {
678
+ $('script').value = x.text; updateCount();
679
+ if (x.note) $('noteIn').value = x.note;
680
+ } }, x.label)));
681
+ CAST.forEach(c => $('castChips').append(el('button', { type: 'button', className: 'chip', onclick: () => {
682
+ $('charName').value = c.name; $('charDesc').value = c.desc; $('charScene').value = c.scene;
683
+ $('script').value = c.text; updateCount(); saveDesign();
684
+ } }, c.label)));
685
+ NOTES.forEach(n => $('noteChips').append(el('button', { type: 'button', className: 'chip', onclick: () => { $('noteIn').value = n; retake(); } }, n)));
686
+ }
687
+
688
+ // ── Voice modes ─────────────────────────────────────────────────────────────
689
+ function setMode(mode) {
690
+ state.mode = mode; saved.set('mode', mode);
691
+ document.querySelectorAll('.seg button').forEach(b => b.classList.toggle('on', b.dataset.mode === mode));
692
+ ['clone', 'design', 'random'].forEach(m => $('pane-' + m).classList.toggle('on', m === mode));
693
+ }
694
+ function allVoices() { return [...state.presets, ...state.custom]; }
695
+ function voiceById(id) { return allVoices().find(v => v.id === id); }
696
+ function renderVoices() {
697
+ const box = $('voiceChips');
698
+ box.innerHTML = '';
699
+ allVoices().forEach(v => {
700
+ box.append(el('button', {
701
+ type: 'button', className: 'chip' + (v.id === state.voiceId ? ' on' : ''),
702
+ title: v.transcript, onclick: () => selectVoice(v.id),
703
+ }, v.preset ? v.label : `★ ${v.label}`));
704
+ });
705
+ box.append(el('button', { type: 'button', className: 'chip dashed', onclick: () => $('fileIn').click() }, '⤒ Upload'));
706
+ const rec = el('span', { className: 'rec', id: 'recBox' });
707
+ rec.append(el('button', { type: 'button', className: 'chip dashed', id: 'recBtn', onclick: toggleRec }, '● Record'));
708
+ const meter = el('span', { className: 'meter' }); meter.append(el('i', { id: 'recLevel' }));
709
+ rec.append(meter);
710
+ box.append(rec);
711
+ }
712
+ function selectVoice(id) {
713
+ const v = voiceById(id);
714
+ if (!v) return;
715
+ state.voiceId = id; saved.set('voiceId', id);
716
+ $('transcript').value = v.transcript || '';
717
+ $('transcriptState').innerHTML = v.preset ? '' : (v.transcribed ? '<span class="badge">auto-transcribed</span>' : '<span class="badge">type it in</span>');
718
+ $('voicePreview').src = `api/voices/${encodeURIComponent(id)}.wav`;
719
+ renderVoices();
720
+ }
721
+ function saveDesign() { saved.set('design', { name: $('charName').value, desc: $('charDesc').value, scene: $('charScene').value }); }
722
+ function voicePayload() {
723
+ if (state.mode === 'clone') return { mode: 'clone', id: state.voiceId, transcript: $('transcript').value };
724
+ if (state.mode === 'design') return { mode: 'design', name: $('charName').value || 'Narrator', description: $('charDesc').value, scene: $('charScene').value };
725
+ return { mode: 'random' };
726
+ }
727
+ function voiceLabel() {
728
+ if (state.mode === 'clone') return '🎙 ' + (voiceById(state.voiceId)?.label || 'Clone');
729
+ if (state.mode === 'design') return '✨ ' + ($('charName').value || 'Narrator');
730
+ return '🎲 Surprise voice';
731
+ }
732
+
733
+ // ── Adding voices ───────────────────────────────────────────────────────────
734
+ $('fileIn').addEventListener('change', e => { const f = e.target.files[0]; e.target.value = ''; if (f) uploadVoice(f, f.name.replace(/\.[^.]+$/, '')); });
735
+ async function uploadVoice(blob, label) {
736
+ hideMsg();
737
+ $('transcript').value = ''; $('transcript').placeholder = 'Transcribing…';
738
+ $('transcriptState').innerHTML = '<span class="badge">listening…</span>';
739
+ try {
740
+ const fd = new FormData();
741
+ fd.append('audio', blob, (label || 'voice') + '.wav');
742
+ const res = await fetch('api/voices', { method: 'POST', body: fd });
743
+ const d = await res.json().catch(() => ({}));
744
+ if (!res.ok) throw new Error(d.detail || 'Upload failed.');
745
+ addCustomVoice(d, label);
746
+ if (d.trimmed) showMsg('info', `Trimmed to ${d.duration.toFixed(1)} s at a pause, so the transcript matches.`);
747
+ } catch (e) {
748
+ showMsg('err', e.message);
749
+ selectVoice(state.voiceId);
750
+ } finally {
751
+ $('transcript').placeholder = 'Exact words spoken in the clip';
752
+ }
753
+ }
754
+ function addCustomVoice(d, label) {
755
+ state.custom = state.custom.filter(v => v.id !== d.id);
756
+ state.custom.push({ ...d, label: label || d.label });
757
+ setMode('clone');
758
+ selectVoice(d.id);
759
+ }
760
+
761
+ // ── Recording ───────────────────────────────────────────────────────────────
762
+ const rec = { stream: null, ctx: null, proc: null, chunks: [], on: false, started: 0, raf: 0, peak: 0 };
763
+ async function toggleRec() { if (rec.on) stopRec(); else startRec(); }
764
+ async function startRec() {
765
+ if (!navigator.mediaDevices?.getUserMedia) { showMsg('err', 'Recording needs HTTPS and a microphone.'); return; }
766
+ hideMsg();
767
+ try {
768
+ rec.stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: false, noiseSuppression: false, autoGainControl: false } });
769
+ } catch (e) { showMsg('err', 'Microphone blocked: ' + e.message); return; }
770
+ rec.ctx = new (window.AudioContext || window.webkitAudioContext)();
771
+ const src = rec.ctx.createMediaStreamSource(rec.stream);
772
+ rec.proc = rec.ctx.createScriptProcessor(4096, 1, 1);
773
+ const mute = rec.ctx.createGain(); mute.gain.value = 0;
774
+ rec.chunks = []; rec.on = true; rec.started = performance.now(); rec.peak = 0;
775
+ rec.proc.onaudioprocess = e => {
776
+ if (!rec.on) return;
777
+ const x = new Float32Array(e.inputBuffer.getChannelData(0));
778
+ let p = 0; for (let i = 0; i < x.length; i++) p = Math.max(p, Math.abs(x[i]));
779
+ rec.peak = Math.max(p, rec.peak * 0.85);
780
+ rec.chunks.push(x);
781
+ };
782
+ src.connect(rec.proc); rec.proc.connect(mute); mute.connect(rec.ctx.destination);
783
+ $('recBox').classList.add('live');
784
+ const tick = () => {
785
+ if (!rec.on) return;
786
+ const s = (performance.now() - rec.started) / 1000;
787
+ $('recBtn').textContent = `■ Stop ${fmtTime(s)}`;
788
+ $('recLevel').style.width = Math.min(100, rec.peak * 140) + '%';
789
+ if (s > 25) { stopRec(); return; }
790
+ rec.raf = requestAnimationFrame(tick);
791
+ };
792
+ tick();
793
+ }
794
+ function stopRec() {
795
+ rec.on = false; cancelAnimationFrame(rec.raf);
796
+ rec.stream?.getTracks().forEach(t => t.stop());
797
+ const sr = rec.ctx?.sampleRate || 48000;
798
+ rec.ctx?.close().catch(() => {});
799
+ $('recBox')?.classList.remove('live');
800
+ const total = rec.chunks.reduce((n, c) => n + c.length, 0);
801
+ renderVoices();
802
+ if (total < sr * 0.8) { showMsg('err', 'That was too short. Record at least a couple of seconds.'); return; }
803
+ const pcm = new Float32Array(total);
804
+ let off = 0; rec.chunks.forEach(c => { pcm.set(c, off); off += c.length; });
805
+ const n = state.custom.filter(v => v.label.startsWith('Mic')).length + 1;
806
+ uploadVoice(wavFromFloat(pcm, sr), `Mic ${n}`);
807
+ }
808
+ function wavFromFloat(pcm, sr) {
809
+ const buf = new ArrayBuffer(44 + pcm.length * 2), v = new DataView(buf);
810
+ const w = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
811
+ w(0, 'RIFF'); v.setUint32(4, 36 + pcm.length * 2, true); w(8, 'WAVE'); w(12, 'fmt ');
812
+ v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
813
+ v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
814
+ w(36, 'data'); v.setUint32(40, pcm.length * 2, true);
815
+ for (let i = 0; i < pcm.length; i++) { const s = Math.max(-1, Math.min(1, pcm[i])); v.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); }
816
+ return new Blob([buf], { type: 'audio/wav' });
817
+ }
818
+ function wavFromInt16(parts, sr) {
819
+ const bytes = parts.reduce((n, p) => n + p.byteLength, 0);
820
+ const head = new ArrayBuffer(44), v = new DataView(head);
821
+ const w = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
822
+ w(0, 'RIFF'); v.setUint32(4, 36 + bytes, true); w(8, 'WAVE'); w(12, 'fmt ');
823
+ v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
824
+ v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
825
+ w(36, 'data'); v.setUint32(40, bytes, true);
826
+ return new Blob([head, ...parts], { type: 'audio/wav' });
827
+ }
828
+
829
+ // ── Streaming playback ──────────────────────────────────────────────────────
830
+ const player = {
831
+ ctx: null, node: null, analyser: null, queue: [], firstSoundAt: null, onFirstSound: null, raf: 0,
832
+ ensure(sr) {
833
+ if (this.ctx && this.ctx.sampleRate === sr) { this.ctx.resume(); return; }
834
+ this.ctx?.close().catch(() => {});
835
+ const AC = window.AudioContext || window.webkitAudioContext;
836
+ this.ctx = new AC({ sampleRate: sr });
837
+ this.analyser = this.ctx.createAnalyser();
838
+ this.analyser.fftSize = 1024; this.analyser.smoothingTimeConstant = 0.7;
839
+ this.node = this.ctx.createScriptProcessor(1024, 0, 1);
840
+ this.node.onaudioprocess = e => {
841
+ const out = e.outputBuffer.getChannelData(0);
842
+ let i = 0, wrote = false;
843
+ while (i < out.length && this.queue.length) {
844
+ const seg = this.queue[0];
845
+ const n = Math.min(out.length - i, seg.data.length - seg.pos);
846
+ out.set(seg.data.subarray(seg.pos, seg.pos + n), i);
847
+ seg.pos += n; i += n; wrote = true;
848
+ if (seg.pos >= seg.data.length) this.queue.shift();
849
+ }
850
+ if (i < out.length) out.fill(0, i);
851
+ if (wrote && this.firstSoundAt == null) { this.firstSoundAt = performance.now(); this.onFirstSound?.(); }
852
+ };
853
+ this.node.connect(this.analyser);
854
+ this.analyser.connect(this.ctx.destination);
855
+ this.ctx.resume();
856
+ },
857
+ reset() { this.queue = []; this.firstSoundAt = null; },
858
+ push(f32) { this.queue.push({ data: f32, pos: 0 }); },
859
+ buffered() { return this.queue.reduce((n, s) => n + s.data.length - s.pos, 0) / (this.ctx?.sampleRate || 44100); },
860
+ };
861
+ function drawViz() {
862
+ if (!$('stage').classList.contains('show')) { player.raf = requestAnimationFrame(drawViz); return; }
863
+ const canvas = $('viz');
864
+ const dpr = window.devicePixelRatio || 1;
865
+ const w = canvas.clientWidth, h = canvas.clientHeight;
866
+ if (canvas.width !== w * dpr) { canvas.width = w * dpr; canvas.height = h * dpr; }
867
+ const g = canvas.getContext('2d');
868
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
869
+ g.clearRect(0, 0, w, h);
870
+ const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
871
+ const faint = getComputedStyle(document.documentElement).getPropertyValue('--border').trim();
872
+ const bars = Math.max(24, Math.floor(w / 9));
873
+ const data = new Uint8Array(player.analyser ? player.analyser.frequencyBinCount : 0);
874
+ if (player.analyser) player.analyser.getByteFrequencyData(data);
875
+ const live = player.queue.length > 0;
876
+ for (let b = 0; b < bars; b++) {
877
+ const idx = Math.floor(Math.pow(b / bars, 1.4) * data.length * 0.35);
878
+ const v = data.length ? data[idx] / 255 : 0;
879
+ const bh = Math.max(2, v * (h - 10));
880
+ g.fillStyle = live && v > 0.02 ? accent : faint;
881
+ const x = b * (w / bars) + 2;
882
+ g.beginPath();
883
+ g.roundRect ? g.roundRect(x, (h - bh) / 2, w / bars - 4, bh, 2) : g.rect(x, (h - bh) / 2, w / bars - 4, bh);
884
+ g.fill();
885
+ }
886
+ player.raf = requestAnimationFrame(drawViz);
887
+ }
888
+
889
+ // ── Perform ─────────────────────────────────────────────────────────────────
890
+ function setPerform(label, live) {
891
+ $('performLabel').textContent = label;
892
+ $('performBtn').classList.toggle('live', !!live);
893
+ $('performBtn').disabled = state.busy || !state.engineReady;
894
+ $('stopBtn').classList.toggle('show', state.busy);
895
+ $('retakeBtn').disabled = state.busy || !state.engineReady || !state.current?.id;
896
+ document.querySelectorAll('#noteChips .chip').forEach(c => c.disabled = state.busy || !state.current?.id);
897
+ }
898
+ function idleLabel() { return state.engineReady ? 'Perform' : 'Warming up…'; }
899
+ function b64ToInt16(b64, carry) {
900
+ const bin = atob(b64);
901
+ const bytes = new Uint8Array(carry.length + bin.length);
902
+ bytes.set(carry);
903
+ for (let i = 0; i < bin.length; i++) bytes[carry.length + i] = bin.charCodeAt(i);
904
+ const even = bytes.length - (bytes.length % 2);
905
+ return { pcm: new Int16Array(bytes.buffer, 0, even / 2), carry: bytes.slice(even) };
906
+ }
907
+
908
+ async function perform(options = {}) {
909
+ if (state.busy || !state.engineReady) return;
910
+ const text = $('script').value.trim();
911
+ if (!text) { showMsg('err', 'Write a line for the actor first.'); $('script').focus(); return; }
912
+ if (!options.retake) {
913
+ if (state.mode === 'clone' && !$('transcript').value.trim()) { showMsg('err', 'Add what the reference clip says, so Drama 3 can line the voice up with the words.'); $('transcript').focus(); return; }
914
+ if (state.mode === 'design' && !$('charDesc').value.trim()) { showMsg('err', 'Describe the voice you want to cast.'); $('charDesc').focus(); return; }
915
+ }
916
+ hideMsg();
917
+ const body = { text, voice: voicePayload(), ...state.sampling };
918
+ const parent = options.retake ? state.current : null;
919
+ if (parent) { body.retake_of = parent.id; body.note = options.note; }
920
+
921
+ player.ensure(44100); // inside the click, so autoplay rules allow sound
922
+ player.reset();
923
+ state.busy = true;
924
+ const controller = new AbortController();
925
+ state.controller = controller;
926
+ const take = {
927
+ id: null, n: state.takes.length + 1, voice: parent ? parent.voice : voiceLabel(),
928
+ mode: parent ? parent.mode : state.mode, note: parent ? options.note : null,
929
+ character: parent ? parent.character : (state.mode === 'design' ? ($('charName').value || 'Narrator') : null),
930
+ text, parts: [], sr: 44100, metrics: {}, blob: null,
931
+ };
932
+ const t0 = performance.now();
933
+ let firstChunkAt = null, carry = new Uint8Array(0);
934
+ player.onFirstSound = () => { take.metrics.client_ms = Math.round(player.firstSoundAt - t0); renderMetrics(take, true); };
935
+ showStage(take);
936
+ setPerform('Connecting…', true);
937
+
938
+ try {
939
+ const res = await fetch('api/generate', {
940
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
941
+ body: JSON.stringify(body), signal: controller.signal,
942
+ });
943
+ if (res.status === 401) { location.reload(); return; }
944
+ if (!res.ok) {
945
+ const e = await res.json().catch(() => ({}));
946
+ throw Object.assign(new Error(typeof e.detail === 'string' ? e.detail : `Request failed (${res.status})`), { status: res.status });
947
+ }
948
+ const reader = res.body.getReader();
949
+ const decoder = new TextDecoder();
950
+ let buf = '';
951
+ for (;;) {
952
+ const { done, value } = await reader.read();
953
+ if (done) break;
954
+ buf += decoder.decode(value, { stream: true });
955
+ let cut;
956
+ while ((cut = buf.indexOf('\n\n')) >= 0) {
957
+ const block = buf.slice(0, cut); buf = buf.slice(cut + 2);
958
+ for (const line of block.split('\n')) {
959
+ if (!line.startsWith('data: ')) continue;
960
+ const d = JSON.parse(line.slice(6));
961
+ if (d.type === 'queued') {
962
+ setPerform(d.position <= 1 ? 'Next in line…' : `In line · ${d.position - 1} ahead`, true);
963
+ } else if (d.type === 'retrying') {
964
+ setPerform('Trying once more…', true);
965
+ } else if (d.type === 'start') {
966
+ take.id = d.take_id; take.sr = d.sample_rate;
967
+ if (d.usage) renderUsage(d.usage);
968
+ player.ensure(d.sample_rate);
969
+ setPerform('Performing…', true);
970
+ } else if (d.type === 'audio') {
971
+ const out = b64ToInt16(d.pcm, carry); carry = out.carry;
972
+ if (firstChunkAt == null) firstChunkAt = performance.now();
973
+ if (d.ttfa_ms != null) take.metrics.ttfa_ms = d.ttfa_ms;
974
+ const f32 = new Float32Array(out.pcm.length);
975
+ for (let i = 0; i < out.pcm.length; i++) f32[i] = out.pcm[i] / 32768;
976
+ player.push(f32);
977
+ take.parts.push(new Int16Array(out.pcm));
978
+ take.metrics.audio_s = take.parts.reduce((n, p) => n + p.length, 0) / take.sr;
979
+ renderMetrics(take, true);
980
+ } else if (d.type === 'done') {
981
+ Object.assign(take.metrics, d);
982
+ take.turn = d.turn_text;
983
+ if (d.usage) renderUsage(d.usage);
984
+ if (d.warning) showMsg('info', d.warning);
985
+ } else if (d.type === 'error') {
986
+ throw new Error(d.message);
987
+ }
988
+ }
989
+ }
990
+ }
991
+ if (!take.metrics.total_ms) throw new Error('The take was cut off. Try again.');
992
+ finishTake(take);
993
+ } catch (e) {
994
+ if (e.name === 'AbortError') {
995
+ showMsg('info', 'Cut! Take stopped.');
996
+ if (take.parts.length) finishTake(take, true);
997
+ } else {
998
+ const signIn = e.status === 429 && state.user && !state.user.signed_in
999
+ ? { label: 'Sign in with Hugging Face', href: '/oauth/huggingface/login' } : null;
1000
+ showMsg('err', e.message || 'Something went wrong.', signIn);
1001
+ if (!state.current) $('stage').classList.remove('show'); else showStage(state.current);
1002
+ }
1003
+ } finally {
1004
+ state.busy = false; state.controller = null;
1005
+ setPerform(idleLabel(), false);
1006
+ }
1007
+ }
1008
+ function stopTake() { state.controller?.abort(); player.reset(); }
1009
+ function retake() {
1010
+ const note = $('noteIn').value.trim();
1011
+ if (!state.current?.id) return;
1012
+ if (!note) { showMsg('err', 'Type a note for the actor, or tap one of the suggestions.'); $('noteIn').focus(); return; }
1013
+ perform({ retake: true, note });
1014
+ }
1015
+
1016
+ // ── Takes ───────────────────────────────────────────────────────────────────
1017
+ function showStage(take) {
1018
+ $('stage').classList.add('show');
1019
+ $('takeTitle').textContent = `Take ${take.n}` + (take.note ? ' · retake' : '');
1020
+ $('takeVoice').textContent = take.voice;
1021
+ renderMetrics(take, !take.blob);
1022
+ $('takeAudio').hidden = !take.blob;
1023
+ if (take.blob) { $('takeAudio').src = take.url; }
1024
+ $('downloadBtn').disabled = !take.blob;
1025
+ $('keepBtn').disabled = !take.blob || !take.id;
1026
+ $('keepBtn').style.display = take.mode === 'clone' ? 'none' : '';
1027
+ if (!take.blob) $('stage').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1028
+ }
1029
+ function finishTake(take, partial) {
1030
+ take.blob = wavFromInt16(take.parts, take.sr);
1031
+ take.url = URL.createObjectURL(take.blob);
1032
+ take.partial = !!partial;
1033
+ state.takes.unshift(take);
1034
+ if (take.id && !partial) state.current = take;
1035
+ showStage(take);
1036
+ renderSlate();
1037
+ }
1038
+ function renderMetrics(take, live) {
1039
+ const m = take.metrics, parts = [];
1040
+ const add = (k, v, hot) => parts.push(`<span class="${hot ? 'hot' : ''}">${k} <b>${v}</b></span>`);
1041
+ if (m.ttfa_ms != null) add('first audio', `${m.ttfa_ms} ms`, true);
1042
+ if (m.client_ms != null) add('you heard it', `${m.client_ms} ms`);
1043
+ if (m.speed != null) add('speed', `${m.speed}× real-time`, true);
1044
+ if (m.audio_s != null) add('length', `${m.audio_s.toFixed(1)} s`);
1045
+ if (m.queue_ms) add('queue', `${(m.queue_ms / 1000).toFixed(1)} s`);
1046
+ if (live) add('buffer', `${player.buffered().toFixed(1)} s`);
1047
+ if (m.trimmed_s >= 0.5) add('silence trimmed', `${m.trimmed_s.toFixed(1)} s`);
1048
+ if (m.finish_reason === 'length') add('note', 'hit the length cap');
1049
+ if (m.finish_reason === 'stall') add('note', 'stalled, cut short');
1050
+ if (take.note) parts.push(`<span>note <b>${escapeHtml(take.note)}</b></span>`);
1051
+ $('metrics').innerHTML = parts.join('');
1052
+ }
1053
+ function escapeHtml(s) { return s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
1054
+ function renderSlate() {
1055
+ const box = $('slate'); box.innerHTML = '';
1056
+ state.takes.forEach(take => {
1057
+ const item = el('div', { className: 'slate-item' + (take === state.current ? ' current' : '') });
1058
+ item.append(el('span', { className: 'slate-num' }, `#${take.n}`));
1059
+ const body = el('div', { className: 'slate-body' });
1060
+ body.append(el('div', { className: 'slate-title' }, take.note ? `“${take.note}”` : take.text.replace(/\s+/g, ' ')));
1061
+ body.append(el('div', { className: 'slate-sub' }, `${take.voice} · ${(take.metrics.audio_s || 0).toFixed(1)} s` +
1062
+ (take.metrics.ttfa_ms != null ? ` · ${take.metrics.ttfa_ms} ms to first audio` : '') + (take.partial ? ' · stopped' : '')));
1063
+ item.append(body);
1064
+ const actions = el('div', { className: 'slate-actions' });
1065
+ actions.append(el('button', { type: 'button', className: 'mini', onclick: () => {
1066
+ if (take.id && !take.partial) state.current = take;
1067
+ showStage(take); renderSlate(); $('takeAudio').play().catch(() => {});
1068
+ } }, take === state.current ? '▶ Play' : '▶ Open'));
1069
+ item.append(actions);
1070
+ box.append(item);
1071
+ });
1072
+ }
1073
+ function downloadTake() {
1074
+ const take = state.takes.find(t => t.url === $('takeAudio').src) || state.current;
1075
+ if (!take?.blob) return;
1076
+ const a = el('a', { href: take.url, download: `drama3-take-${take.n}.wav` });
1077
+ document.body.append(a); a.click(); a.remove();
1078
+ }
1079
+ async function keepVoice() {
1080
+ const take = state.current;
1081
+ if (!take?.id) return;
1082
+ $('keepBtn').disabled = true; $('keepBtn').textContent = 'Keeping…';
1083
+ try {
1084
+ const label = take.character || `Found ${state.custom.length + 1}`;
1085
+ const res = await fetch('api/voices/from-take', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ take_id: take.id, label }) });
1086
+ const d = await res.json().catch(() => ({}));
1087
+ if (!res.ok) throw new Error(d.detail || 'Could not keep that voice.');
1088
+ addCustomVoice(d, label);
1089
+ showMsg('info', `Saved “${label}” as a clone voice. New lines will keep this voice.`);
1090
+ } catch (e) { showMsg('err', e.message); }
1091
+ finally { $('keepBtn').textContent = 'Keep this voice'; $('keepBtn').disabled = false; }
1092
+ }
1093
+
1094
+ // ── Status ──────────────────────────────────────────────────────────────────
1095
+ async function poll() {
1096
+ let delay = 2500;
1097
+ try {
1098
+ const res = await fetch('api/status', { cache: 'no-store' });
1099
+ if (res.status === 401) { location.reload(); return; }
1100
+ const d = await res.json();
1101
+ applyStatus(d);
1102
+ delay = state.engineReady ? 8000 : 2500;
1103
+ } catch {
1104
+ $('statusDot').className = 'dot err'; $('statusText').textContent = 'offline';
1105
+ }
1106
+ setTimeout(poll, delay);
1107
+ }
1108
+ function applyStatus(d) {
1109
+ renderUser(d.user, d.usage);
1110
+ const e = d.engine;
1111
+ state.maxChars = d.limits?.max_text_chars;
1112
+ if (!state.presets.length && d.voices?.length) {
1113
+ state.presets = d.voices;
1114
+ if (!voiceById(state.voiceId)) state.voiceId = d.voices[0].id;
1115
+ selectVoice(state.voiceId);
1116
+ }
1117
+ state.transcriber = d.transcriber;
1118
+ const wasReady = state.engineReady;
1119
+ state.engineReady = e.state === 'ready';
1120
+ const boot = $('boot');
1121
+ boot.classList.toggle('show', !state.engineReady);
1122
+ boot.classList.toggle('error', e.state === 'error');
1123
+ if (!state.engineReady) {
1124
+ $('bootTitle').textContent = e.state === 'error' ? 'The stage is dark' : e.state === 'restarting' ? 'Restarting the engine…' : 'Setting the stage…';
1125
+ $('bootPhase').textContent = e.state === 'error' ? e.phase : `${e.phase} · ${fmtTime(e.elapsed_s)}. The page works meanwhile: write a script and pick a voice.`;
1126
+ $('bootLog').textContent = e.log || '';
1127
+ }
1128
+ const gpu = d.gpu?.name || 'GPU';
1129
+ const dot = $('statusDot'), txt = $('statusText');
1130
+ if (state.engineReady) {
1131
+ const busy = d.load.active + d.load.waiting;
1132
+ dot.className = 'dot ready';
1133
+ const bits = [`<b>${escapeHtml(gpu)}</b>`];
1134
+ bits.push(busy ? `${busy} performing` : 'ready');
1135
+ if (d.stats.ttfa_ms_p50 != null) bits.push(`${d.stats.ttfa_ms_p50} ms to first audio`);
1136
+ txt.innerHTML = bits.join(' · ');
1137
+ } else {
1138
+ dot.className = 'dot ' + (e.state === 'error' ? 'err' : 'busy');
1139
+ txt.innerHTML = `<b>${escapeHtml(gpu)}</b> · ${e.state === 'error' ? 'down' : 'warming up'}`;
1140
+ }
1141
+ if (wasReady !== state.engineReady && !state.busy) setPerform(idleLabel(), false);
1142
+ updateCount();
1143
+ }
1144
+
1145
+ // ── Account ─────────────────────────────────────────────────────────────────
1146
+ function renderUser(user, usage) {
1147
+ state.user = user && user.username !== 'local' ? user : null;
1148
+ $('account').hidden = !state.user || !state.user.signed_in;
1149
+ $('signIn').hidden = !state.user || state.user.signed_in;
1150
+ renderUsage(usage);
1151
+ }
1152
+ function renderUsage(usage) {
1153
+ if (!usage || !state.user) return;
1154
+ if (!state.user.signed_in) {
1155
+ const left = Math.max(0, usage.remaining ?? 0);
1156
+ $('signIn').textContent = `Sign in · ${left} guest take${left === 1 ? '' : 's'} left`;
1157
+ $('signIn').title = `Signing in with Hugging Face adds ${usage.free_after_sign_in} takes a day; PRO is unlimited.`;
1158
+ return;
1159
+ }
1160
+ const left = Math.max(0, usage.remaining ?? 0);
1161
+ const badge = usage.unlimited ? (usage.tier === 'pro' ? 'PRO' : 'team') : `${left} left`;
1162
+ $('userPill').textContent = `${state.user.username} · ${badge}`;
1163
+ $('accountSummary').textContent = usage.unlimited
1164
+ ? `${badge} · unlimited takes`
1165
+ : `Free · ${left} of ${usage.limit} takes left today (resets at midnight UTC)`;
1166
+ }
1167
+ function toggleAccount(event) {
1168
+ event.stopPropagation();
1169
+ const menu = $('accountMenu');
1170
+ menu.hidden = !menu.hidden;
1171
+ $('userPill').setAttribute('aria-expanded', String(!menu.hidden));
1172
+ }
1173
+ document.addEventListener('click', e => { if (!$('account').contains(e.target)) $('accountMenu').hidden = true; });
1174
+
1175
+ // ── Settings ────────────────────────────────────────────────────────────────
1176
+ function bindSlider(id, key, fmt) {
1177
+ const input = $(id), out = $(id + 'V');
1178
+ const sync = () => { out.textContent = fmt(state.sampling[key]); input.value = state.sampling[key]; };
1179
+ input.addEventListener('input', () => { state.sampling[key] = Number(input.value); saved.set(SAMPLING_KEY, state.sampling); sync(); });
1180
+ sync();
1181
+ return sync;
1182
+ }
1183
+ const syncs = [
1184
+ bindSlider('temp', 'temperature', v => v.toFixed(2)),
1185
+ bindSlider('topp', 'top_p', v => v.toFixed(2)),
1186
+ bindSlider('topk', 'top_k', v => v ? String(v) : 'off'),
1187
+ ];
1188
+ function resetSampling() { state.sampling = { ...DEFAULT_SAMPLING }; saved.set(SAMPLING_KEY, state.sampling); syncs.forEach(s => s()); }
1189
+
1190
+ // ── Init ────────────────────────────────────────────────────────────────────
1191
+ renderPalette();
1192
+ const design = saved.get('design', null) || CAST[0];
1193
+ $('charName').value = design.name; $('charDesc').value = design.desc; $('charScene').value = design.scene || '';
1194
+ ['charName', 'charDesc', 'charScene'].forEach(id => $(id).addEventListener('input', saveDesign));
1195
+ $('script').value = saved.get('script', EXAMPLES[0].text);
1196
+ $('script').addEventListener('input', updateCount);
1197
+ updateCount();
1198
+ setMode(['clone', 'design', 'random'].includes(state.mode) ? state.mode : 'random');
1199
+ renderVoices();
1200
+ setPerform(idleLabel(), false);
1201
+ drawViz();
1202
+ poll();
1203
+ document.addEventListener('keydown', e => {
1204
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); if (document.activeElement === $('noteIn')) retake(); else perform(); }
1205
+ if (e.key === 'Escape') { closeSheet(); $('accountMenu').hidden = true; }
1206
+ });
1207
+ $('noteIn').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); retake(); } });
1208
+ </script>
1209
+ </body>
1210
+ </html>
requirements-preview.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Shared UI dependencies. CPU deployment adds requirements-ui.txt; GPU uses Dockerfile.engine.
2
+ gradio[oauth]==6.28.0
3
+ huggingface-hub[oauth]>=1.5.0,<2
4
+ numpy
5
+ soundfile
6
+ librosa
7
+ uvicorn
8
+ python-multipart
requirements-ui.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ -r requirements-preview.txt
2
+ faster-whisper==1.2.1
3
+ av
4
+ httpx[http2]
5
+ fastapi
6
+ itsdangerous
7
+ authlib
requirements.txt DELETED
@@ -1,15 +0,0 @@
1
- gradio==6.30.0
2
- spaces
3
- torch==2.13.0
4
- torchvision==0.28.0
5
- transformers>=5.0
6
- huggingface_hub
7
- safetensors
8
- tokenizers
9
- loguru
10
- tqdm
11
- einops
12
- numpy
13
- librosa
14
- soundfile
15
- httpx
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/omni_missing_deps.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pins of SGLang Omni's pyproject.toml that this environment does not satisfy.
2
+
3
+ The base image moves slower than SGLang Omni's main branch, so its CI installs these
4
+ before testing a commit (.github/scripts/omni_missing_dependencies.py there); the
5
+ Dockerfile does the same for the pinned commit.
6
+
7
+ python3 omni_missing_deps.py pyproject.toml # requirements to install
8
+ python3 omni_missing_deps.py --overrides pyproject.toml # uv overrides, for --no-deps
9
+ python3 omni_missing_deps.py --report pyproject.toml # what is still unsatisfied
10
+ """
11
+
12
+ import argparse
13
+ import importlib.metadata
14
+ import tomllib
15
+
16
+ from packaging.requirements import Requirement
17
+
18
+ EXTRAS = ("fishaudio",)
19
+
20
+
21
+ def unsatisfied(specs: list[str]) -> list[str]:
22
+ missing = []
23
+ for spec in specs:
24
+ requirement = Requirement(spec)
25
+ if requirement.marker and not requirement.marker.evaluate():
26
+ continue
27
+ try:
28
+ installed = importlib.metadata.version(requirement.name)
29
+ except importlib.metadata.PackageNotFoundError:
30
+ missing.append(spec)
31
+ continue
32
+ if not requirement.specifier.contains(installed, prereleases=True):
33
+ missing.append(spec)
34
+ return missing
35
+
36
+
37
+ def main() -> None:
38
+ parser = argparse.ArgumentParser()
39
+ parser.add_argument("pyproject")
40
+ parser.add_argument("--overrides", action="store_true")
41
+ parser.add_argument("--report", action="store_true")
42
+ args = parser.parse_args()
43
+ with open(args.pyproject, "rb") as f:
44
+ config = tomllib.load(f)
45
+ overrides = config.get("tool", {}).get("uv", {}).get("override-dependencies", [])
46
+ if args.overrides:
47
+ print("\n".join(overrides))
48
+ return
49
+ project = config["project"]
50
+ specs = list(project["dependencies"])
51
+ for extra in EXTRAS:
52
+ specs += project["optional-dependencies"][extra]
53
+ missing = unsatisfied(specs)
54
+ if args.report:
55
+ missing += unsatisfied(overrides)
56
+ print("All SGLang Omni pins satisfied." if not missing else
57
+ "Still unsatisfied (check the engine log if it fails to start):\n " + "\n ".join(missing))
58
+ return
59
+ print("\n".join(missing))
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()