Skip to content

feat(tts): add voice upload API for Qwen3-TTS#1201

Open
zhaotyer wants to merge 5 commits intovllm-project:mainfrom
zhaotyer:add_custom_voice
Open

feat(tts): add voice upload API for Qwen3-TTS#1201
zhaotyer wants to merge 5 commits intovllm-project:mainfrom
zhaotyer:add_custom_voice

Conversation

@zhaotyer
Copy link

@zhaotyer zhaotyer commented Feb 4, 2026

  • Add POST /v1/audio/voices endpoint for uploading custom voice samples
  • modify GET /v1/audio/voices endpoint for listing available voices
  • Update API documentation with voice management examples
  • Support voice cloning with uploaded samples in Base task

The new endpoints allow users to:

  1. Upload custom voice samples (max 10MB, various audio formats)
  2. List all available voices (built-in + uploaded)
  3. Use uploaded voices for voice cloning in TTS requests

Purpose

support add voice upload API for Qwen3-TTS

Test Plan

Test Result


Details Files changed: - vllm_omni/entrypoints/openai/api_server.py - vllm_omni/entrypoints/openai/serving_speech.py - examples/online_serving/qwen3_tts/README.md

BEFORE SUBMITTING, PLEASE READ https://github.com/vllm-project/vllm-omni/blob/main/CONTRIBUTING.md (anything written below this line will be removed by GitHub Actions)

@zhaotyer
Copy link
Author

zhaotyer commented Feb 4, 2026

  • Add POST /v1/audio/voices endpoint for uploading custom voice samples
  • modify GET /v1/audio/voices endpoint for listing available voices
  • Update API documentation with voice management examples
  • Support voice cloning with uploaded samples in Base task

The new endpoints allow users to:

  1. Upload custom voice samples (max 10MB, various audio formats)
  2. List all available voices (built-in + uploaded)
  3. Use uploaded voices for voice cloning in TTS requests

Purpose

support add voice upload API for Qwen3-TTS

Test Plan

Test Result

Details
BEFORE SUBMITTING, PLEASE READ https://github.com/vllm-project/vllm-omni/blob/main/CONTRIBUTING.md (anything written below this line will be removed by GitHub Actions)

@hsliuustc0106

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9e405d2eff

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 256 to 259
# Validate Base task requirements
if task_type == "Base":
if task_type == "Base" and request.voice is None:
if request.ref_audio is None:
return "Base task requires 'ref_audio' for voice cloning"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require ref_audio for Base when voice isn't uploaded

The new Base-task validation only enforces ref_audio when voice is missing, so a request like task_type=Base with a built-in speaker name but no ref_audio now passes validation. In that case _build_tts_params will send no ref_audio to the model (because the auto-fill only happens for uploaded voices), which breaks the Base task’s voice-cloning requirement and likely yields a model error or incorrect output. Consider requiring ref_audio unless voice refers to an uploaded speaker that will be auto-populated.

Useful? React with 👍 / 👎.

Comment on lines 183 to 188
# Generate filename
timestamp = int(time.time())
file_ext = audio_file.filename.split('.')[-1] if '.' in audio_file.filename else "wav"
filename = f"{name}_{consent}_{timestamp}.{file_ext}"
file_path = self.uploaded_speakers_dir / filename

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent path traversal in uploaded voice filename

The upload endpoint builds filename directly from untrusted name and consent and then writes file_path = self.uploaded_speakers_dir / filename. If either field contains path separators or .., the resulting path can escape /tmp/voice_samples and overwrite arbitrary files on the host. This is a security issue that can be triggered by a client POSTing a crafted name/consent. Sanitize these inputs (e.g., allowlist safe characters) or normalize and validate that the resolved path stays within the upload directory.

Useful? React with 👍 / 👎.

@linyueqian
Copy link
Contributor

A few thoughts: (1) Consider supporting pre-extracted embedding uploads (.pt/.npy) in addition to audio files to skip extraction overhead at inference time. (2) The /tmp/voice_samples storage is volatile. Maybe make this path configurable or document this limitation. (3) Missing a DELETE endpoint to remove uploaded voices.

@hsliuustc0106 hsliuustc0106 requested a review from Copilot February 5, 2026 04:37
@hsliuustc0106
Copy link
Collaborator

please also update the docs as well in apiserver

@linyueqian
Copy link
Contributor

please also update the docs as well in apiserver

we probably need to merge #1206 first

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds voice upload functionality for Qwen3-TTS, allowing users to upload custom voice samples for voice cloning. The implementation adds new API endpoints for uploading and listing voice samples, along with automatic integration into the TTS workflow.

Changes:

  • Added POST /v1/audio/voices endpoint for uploading custom voice samples (max 10MB)
  • Modified GET /v1/audio/voices endpoint to return both built-in and uploaded voices
  • Implemented auto-set behavior that automatically uses uploaded voice audio for Base task TTS requests

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 20 comments.

File Description
vllm_omni/entrypoints/openai/serving_speech.py Core voice upload logic including file storage, metadata management, and auto-set ref_audio behavior for uploaded voices
vllm_omni/entrypoints/openai/api_server.py API endpoint definitions for voice upload and enhanced voice listing with uploaded voice details
examples/online_serving/qwen3_tts/README.md Documentation for new voice management endpoints with usage examples

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


# Validate Base task requirements
if task_type == "Base":
if task_type == "Base" and request.voice is None:
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation doesn't check if an uploaded voice file actually exists when using Base task with an uploaded voice. If task_type is "Base" and voice is an uploaded voice name, but the audio file is missing or unreadable, the auto-set logic at lines 320-325 will silently fail (returning None from _get_uploaded_audio_data), and the Base task will proceed without ref_audio, potentially causing downstream errors. Consider adding validation to ensure uploaded voices have accessible audio files, especially for Base task.

Suggested change
if task_type == "Base" and request.voice is None:
if task_type == "Base":
# Base task always requires explicit ref_audio to avoid relying on
# potentially failing auto-set logic from uploaded voices.

Copilot uses AI. Check for mistakes.
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize uploaded speakers storage
self.uploaded_speakers_dir = Path("/tmp/voice_samples")
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a hardcoded path '/tmp/voice_samples' poses several issues:

  1. Security: Multiple users/deployments on the same system will share this directory
  2. Persistence: Files in /tmp may be deleted by system cleanup processes
  3. Portability: This path may not work on all operating systems (e.g., Windows)

Consider using a configurable directory path that can be set via environment variable or configuration parameter, and ensure proper isolation for multi-tenant scenarios.

Suggested change
self.uploaded_speakers_dir = Path("/tmp/voice_samples")
base_dir_env = os.getenv("VLLM_OMNI_VOICE_SAMPLES_DIR")
if base_dir_env:
self.uploaded_speakers_dir = Path(base_dir_env)
else:
# Use a portable, user-specific cache directory by default
xdg_cache_home = os.getenv("XDG_CACHE_HOME")
if xdg_cache_home:
cache_base = Path(xdg_cache_home)
else:
cache_base = Path.home() / ".cache"
self.uploaded_speakers_dir = cache_base / "vllm_omni" / "voice_samples"

Copilot uses AI. Check for mistakes.
Comment on lines 188 to 222

# Save audio file
try:
with open(file_path, 'wb') as f:
content = await audio_file.read()
f.write(content)
except Exception as e:
raise ValueError(f"Failed to save audio file: {e}")

# Update metadata
self.uploaded_speakers[voice_name_lower] = {
"name": name,
"consent": consent,
"file_path": str(file_path),
"created_at": timestamp,
"mime_type": mime_type,
"original_filename": audio_file.filename,
"file_size": file_size
}

# Update supported speakers
self.supported_speakers.add(voice_name_lower)

# Save metadata
self._save_uploaded_speakers()

logger.info(f"Uploaded new voice '{name}' with consent ID '{consent}'")

return {
"name": name,
"consent": consent,
"file_path": str(file_path),
"created_at": timestamp,
"mime_type": mime_type,
"file_size": file_size
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race condition: if the file is successfully written but saving metadata fails, the uploaded file becomes orphaned. Consider using a transaction-like pattern where you first save the file with a temporary name, then update metadata, and only rename to final name if both succeed. Also consider cleanup of orphaned files on initialization.

Suggested change
# Save audio file
try:
with open(file_path, 'wb') as f:
content = await audio_file.read()
f.write(content)
except Exception as e:
raise ValueError(f"Failed to save audio file: {e}")
# Update metadata
self.uploaded_speakers[voice_name_lower] = {
"name": name,
"consent": consent,
"file_path": str(file_path),
"created_at": timestamp,
"mime_type": mime_type,
"original_filename": audio_file.filename,
"file_size": file_size
}
# Update supported speakers
self.supported_speakers.add(voice_name_lower)
# Save metadata
self._save_uploaded_speakers()
logger.info(f"Uploaded new voice '{name}' with consent ID '{consent}'")
return {
"name": name,
"consent": consent,
"file_path": str(file_path),
"created_at": timestamp,
"mime_type": mime_type,
"file_size": file_size
temp_file_path = self.uploaded_speakers_dir / f"{filename}.tmp"
# Save audio file to a temporary path first to avoid orphaned files
try:
content = await audio_file.read()
with open(temp_file_path, "wb") as f:
f.write(content)
# Update metadata in memory
self.uploaded_speakers[voice_name_lower] = {
"name": name,
"consent": consent,
"file_path": str(file_path),
"created_at": timestamp,
"mime_type": mime_type,
"original_filename": audio_file.filename,
"file_size": file_size,
}
# Update supported speakers
self.supported_speakers.add(voice_name_lower)
# Persist metadata
self._save_uploaded_speakers()
# Atomically move the temp file to its final location
os.replace(temp_file_path, file_path)
except Exception as e:
# Clean up temp file and roll back in-memory state on failure
try:
if isinstance(temp_file_path, Path):
if temp_file_path.exists():
temp_file_path.unlink()
else:
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
except Exception:
# Best-effort cleanup; ignore secondary errors
pass
# Roll back any partially updated metadata
if hasattr(self, "uploaded_speakers"):
self.uploaded_speakers.pop(voice_name_lower, None)
if hasattr(self, "supported_speakers"):
try:
self.supported_speakers.discard(voice_name_lower)
except AttributeError:
# In case supported_speakers is not a set-like object
try:
self.supported_speakers.remove(voice_name_lower)
except Exception:
pass
raise ValueError(f"Failed to upload voice: {e}")
logger.info(f"Uploaded new voice '{name}' with consent ID '{consent}'")
return {
"name": name,
"consent": consent,
"file_path": str(file_path),
"created_at": timestamp,
"mime_type": mime_type,
"file_size": file_size,

Copilot uses AI. Check for mistakes.
Comment on lines +93 to +100
def _save_uploaded_speakers(self) -> None:
"""Save uploaded speakers to metadata file."""
try:
metadata = {"uploaded_speakers": self.uploaded_speakers}
with open(self.metadata_file, 'w') as f:
json.dump(metadata, f, indent=2)
except Exception as e:
logger.error(f"Could not save uploaded speakers metadata: {e}")
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata.json file could grow unbounded as users upload more voices. There's no mechanism to limit the number of uploaded voices or to delete old voices. Consider implementing:

  1. A maximum number of uploaded voices per instance
  2. An API endpoint to delete uploaded voices
  3. A cleanup mechanism for old/unused voices

Copilot uses AI. Check for mistakes.
Comment on lines +93 to +100
def _save_uploaded_speakers(self) -> None:
"""Save uploaded speakers to metadata file."""
try:
metadata = {"uploaded_speakers": self.uploaded_speakers}
with open(self.metadata_file, 'w') as f:
json.dump(metadata, f, indent=2)
except Exception as e:
logger.error(f"Could not save uploaded speakers metadata: {e}")
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata file is not protected by any locking mechanism. In a multi-process or multi-threaded environment, concurrent uploads could lead to race conditions where:

  1. Two processes read the same metadata
  2. Both add their voice
  3. One overwrites the other's changes when saving

Consider using file locking (e.g., fcntl on Unix, msvcrt on Windows) or a database for thread-safe metadata storage.

Copilot uses AI. Check for mistakes.
@@ -1,7 +1,11 @@
import asyncio
import json
import os
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'os' module is imported but never used in the code. This import should be removed to keep the codebase clean.

Suggested change
import os

Copilot uses AI. Check for mistakes.
"voice": {
"name": "custom_voice_1",
"consent": "user_consent_id",
"file_path": "/tmp/voice_samples/custom_voice_1_user_consent_id_1738660000.wav",
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation exposes the internal file path '/tmp/voice_samples/' in the response example. This is a potential information disclosure issue as it reveals the server's internal directory structure. Consider either:

  1. Not returning the file_path in the API response
  2. Sanitizing the path to not reveal absolute server paths
  3. Returning a relative or opaque identifier instead
Suggested change
"file_path": "/tmp/voice_samples/custom_voice_1_user_consent_id_1738660000.wav",
"file_path": "custom_voice_1_user_consent_id_1738660000.wav",

Copilot uses AI. Check for mistakes.

# Generate filename
timestamp = int(time.time())
file_ext = audio_file.filename.split('.')[-1] if '.' in audio_file.filename else "wav"
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file extension extraction logic is fragile. If the filename has no extension or multiple dots (e.g., 'my.voice.sample.wav'), splitting by '.' and taking the last element works, but if there's no dot in the filename, the entire filename becomes the extension. This should be handled more robustly, perhaps by using Path(audio_file.filename).suffix or providing a default extension if none is found.

Suggested change
file_ext = audio_file.filename.split('.')[-1] if '.' in audio_file.filename else "wav"
raw_filename = audio_file.filename or ""
suffix = Path(raw_filename).suffix.lstrip(".")
file_ext = suffix if suffix else "wav"

Copilot uses AI. Check for mistakes.
Comment on lines +850 to +851
consent: str = Form(...),
name: str = Form(...),
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The consent parameter is stored but never validated or used for any authorization checks. If consent is meant to represent user consent for voice cloning, there should be validation logic to verify:

  1. The consent ID format/validity
  2. Whether the consent is still active
  3. Logging/audit trail for consent usage

Without proper consent validation, this could lead to compliance issues with privacy regulations.

Copilot uses AI. Check for mistakes.

#### POST /v1/audio/voices

Upload a new voice sample for voice cloning in Base task TTS requests.
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that uploaded voices can be used "for voice cloning in Base task TTS requests", but the implementation doesn't enforce that uploaded voices are only used with Base task. An uploaded voice can be used with any task type due to the auto-set logic at lines 320-325, which could lead to unexpected behavior. Consider either:

  1. Clarifying in the documentation that uploaded voices work with any task type
  2. Restricting uploaded voices to Base task only in the code
  3. Making the auto-set behavior conditional on task_type being "Base"
Suggested change
Upload a new voice sample for voice cloning in Base task TTS requests.
Upload a new voice sample that can be used for voice cloning in subsequent TTS requests with any supported task type.

Copilot uses AI. Check for mistakes.
@zhaotyer
Copy link
Author

zhaotyer commented Feb 5, 2026

please also update the docs as well in apiserver

already add docs in apiserver, copy from #1206

@linyueqian
Copy link
Contributor

A few issues from the Copilot review still look unaddressed after the latest commit:

Security (must fix before merge):

  1. Path traversal: name and consent are used directly in the filename (f"{name}_{consent}_{timestamp}.{file_ext}"). A crafted name like ../../etc/cron.d/evil escapes the upload directory. Sanitize to alphanumeric/underscore/hyphen only, or validate the resolved path stays within uploaded_speakers_dir.

  2. File path disclosure: The API response returns the full server path (/tmp/voice_samples/...). Drop file_path from the response or return an opaque identifier instead.

Logic bugs (must fix):

  1. Base task validation bypass: The change if task_type == "Base" and request.voice is None means a request with a built-in speaker name (e.g. voice=vivian) + task_type=Base + no ref_audio now passes validation. The auto-fill only kicks in for uploaded voices, so this will break downstream. Should check request.voice.lower() in self.uploaded_speakers specifically.

  2. Silent auto-set failure: If an uploaded voice's audio file is missing/deleted, _get_uploaded_audio_data returns None silently, and the Base task proceeds without ref_audio. Should return an error instead.

Minor (nice to have):

  1. Move import base64 to top of file
  2. Use Path(filename).suffix instead of split('.')[-1] for extension extraction
  3. The consent/name emptiness checks in api_server.py are dead code since Form(...) already enforces required
  4. Docs response examples still show /tmp/voice_samples/ paths

Also heads up: PR #1227 adds speaker_embedding support and touches the same validation/param-building code. Coordinating so #1227 rebases after this lands (commented there already).

@zhaotyer
Copy link
Author

zhaotyer commented Feb 6, 2026

A few issues from the Copilot review still look unaddressed after the latest commit:

Security (must fix before merge):

  1. Path traversal: name and consent are used directly in the filename (f"{name}_{consent}_{timestamp}.{file_ext}"). A crafted name like ../../etc/cron.d/evil escapes the upload directory. Sanitize to alphanumeric/underscore/hyphen only, or validate the resolved path stays within uploaded_speakers_dir.
  2. File path disclosure: The API response returns the full server path (/tmp/voice_samples/...). Drop file_path from the response or return an opaque identifier instead.

Logic bugs (must fix):

  1. Base task validation bypass: The change if task_type == "Base" and request.voice is None means a request with a built-in speaker name (e.g. voice=vivian) + task_type=Base + no ref_audio now passes validation. The auto-fill only kicks in for uploaded voices, so this will break downstream. Should check request.voice.lower() in self.uploaded_speakers specifically.
  2. Silent auto-set failure: If an uploaded voice's audio file is missing/deleted, _get_uploaded_audio_data returns None silently, and the Base task proceeds without ref_audio. Should return an error instead.

Minor (nice to have):

  1. Move import base64 to top of file
  2. Use Path(filename).suffix instead of split('.')[-1] for extension extraction
  3. The consent/name emptiness checks in api_server.py are dead code since Form(...) already enforces required
  4. Docs response examples still show /tmp/voice_samples/ paths

Also heads up: PR #1227 adds speaker_embedding support and touches the same validation/param-building code. Coordinating so #1227 rebases after this lands (commented there already).

you are right,i will fix it

marksverdhai pushed a commit to marksverdhai/ht-vllm-omni that referenced this pull request Feb 6, 2026


Port the voice upload API (POST /v1/audio/voices) from upstream
vllm-project#1201 into the HT branch, adapted to coexist
with HT's existing streaming and audio extraction changes.

- Add upload_voice(), _load/_save_uploaded_speakers() to serving_speech
- Add POST /v1/audio/voices endpoint to api_server
- Modify GET /v1/audio/voices to include uploaded voice details
- Auto-set ref_audio for uploaded voices in Base task
- Add docs/serving/speech_api.md documentation

Note: Known upstream review issues (path traversal, metadata locking,
validation bypass for built-in voices) are carried as-is for parity
and will be addressed in a follow-up.

Upstream-PR: vllm-project#1201
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
marksverdhai pushed a commit to marksverdhai/ht-vllm-omni that referenced this pull request Feb 6, 2026
Fixes security and logic issues flagged in upstream PR vllm-project#1201 review:

Security:
- Sanitize name/consent to alphanumeric/underscore/hyphen only
- Validate resolved path stays within upload directory
- Remove file_path from API responses (information disclosure)

Logic bugs:
- Base task validation now correctly requires ref_audio unless voice
  is specifically an uploaded voice (not just any voice name)
- _get_uploaded_audio_data raises ValueError instead of returning None
  when audio file is missing, preventing silent failures

Robustness:
- Atomic metadata writes via tempfile + os.replace
- File locking (fcntl.flock) on metadata.json reads and writes
- Use Path().suffix for file extension extraction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zhaotyer and others added 2 commits February 7, 2026 17:37
- Prevent path traversal attacks in filename handling
- Remove file_path from API responses
- Update documentation
- Improve validation logic
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants