Spaces:
Running on Zero
Running on Zero
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.
- .dockerignore +8 -0
- .gitattributes +1 -0
- .gitignore +4 -0
- Dockerfile +23 -0
- Dockerfile.engine +65 -0
- README.md +217 -79
- accounts.py +262 -0
- app.py +0 -776
- drama3_space_ext.py +292 -0
- endpoint/.gitignore +6 -0
- endpoint/PERFORMANCE.md +78 -0
- endpoint/README.md +143 -0
- endpoint/analyze_comparison.py +155 -0
- endpoint/benchmark_speed.py +138 -0
- endpoint/benchmark_transport.py +135 -0
- endpoint/compare.py +169 -0
- endpoint/deploy.py +51 -0
- endpoint/deployment.json +50 -0
- endpoint/verify.py +71 -0
- endpoint/watch.py +38 -0
- entitlements.py +134 -0
- fish_speech/LICENSE +0 -114
- fish_speech/__init__.py +0 -1
- fish_speech/codec.py +0 -113
- fish_speech/models/__init__.py +0 -1
- fish_speech/models/dac/__init__.py +0 -1
- fish_speech/models/dac/dac_nn.py +0 -285
- fish_speech/models/dac/modded_dac.py +0 -991
- fish_speech/models/dac/rvq.py +0 -371
- fish_speech/models/text2semantic/__init__.py +0 -1
- fish_speech/models/text2semantic/errors.py +0 -5
- fish_speech/models/text2semantic/fish_omni/__init__.py +0 -1
- fish_speech/models/text2semantic/fish_omni/backbone.py +0 -397
- fish_speech/models/text2semantic/fish_omni/chunking.py +0 -206
- fish_speech/models/text2semantic/fish_omni/config.py +0 -249
- fish_speech/models/text2semantic/fish_omni/fp8_moe.py +0 -355
- fish_speech/models/text2semantic/fish_omni/generate.py +0 -505
- fish_speech/models/text2semantic/fish_omni/layers.py +0 -410
- fish_speech/models/text2semantic/fish_omni/model.py +0 -147
- fish_speech/models/text2semantic/fish_omni/moe.py +0 -223
- fish_speech/models/text2semantic/fish_omni/tower.py +0 -421
- fish_speech/models/text2semantic/sampling.py +0 -9
- footer.html +11 -0
- gradio_ui.py +479 -0
- hero.html +13 -0
- index.html +1210 -0
- requirements-preview.txt +8 -0
- requirements-ui.txt +7 -0
- requirements.txt +0 -15
- 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:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
startup_duration_timeout:
|
| 14 |
models:
|
| 15 |
- fishaudio/drama-3
|
| 16 |
-
|
| 17 |
-
short_description: Fish Audio Drama 3 Work Space!
|
| 18 |
tags:
|
| 19 |
-
- ml-intern
|
| 20 |
- text-to-speech
|
| 21 |
- fish-audio
|
| 22 |
-
-
|
|
|
|
|
|
|
|
|
|
| 23 |
---
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
**[fishaudio/drama-3](https://huggingface.co/fishaudio/drama-3)** (35B MoE, 3B
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
Inference
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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://
|
| 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
|
| 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 |
-
##
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 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>< ></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 <whisper>you won't tell her</whisper>.</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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
| 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(/<([A-Za-z][\w-]*)>([\s\S]*?)<\/\1>/g, (_, name, body) =>
|
| 635 |
+
`<mark class="h-tag"><${name}></mark><span class="h-span">${body}</span><mark class="h-tag"></${name}></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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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()
|