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 138
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 138
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.
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
Copy link
Author

zhaotyer commented Feb 9, 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).

I have made changes to these parts and added the speaker emb caching feature.
@linyueqian

@hsliuustc0106 hsliuustc0106 requested a review from Copilot February 9, 2026 07:23
@hsliuustc0106
Copy link
Collaborator

fix precommits&DCO please

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

Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.


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

Comment on lines +137 to +149
**Response Example:**

```json
{
"success": true,
"voice": {
"name": "custom_voice_1",
"consent": "user_consent_id",
"file_path": "/tmp/voice_samples/custom_voice_1_user_consent_id_1738660000.wav",
"created_at": 1738660000,
"mime_type": "audio/wav",
"file_size": 1024000
}
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

The documented response example for POST /v1/audio/voices includes file_path, but the implementation intentionally does not return server file paths. Update the example to match the actual response schema (and also document the updated GET /v1/audio/voices response shape, which now includes uploaded_voices).

Copilot uses AI. Check for mistakes.
Comment on lines 839 to 885
@router.post(
"/v1/audio/voices",
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def upload_voice(
raw_request: Request,
audio_sample: UploadFile = File(...),
consent: str = Form(...),
name: str = Form(...),
):
"""Upload a new voice sample for voice cloning.

Uploads an audio file that can be used as a reference for voice cloning
in Base task TTS requests. The voice can then be referenced by name
in subsequent TTS requests.

Args:
audio_sample: Audio file (max 10MB)
consent: Consent recording ID
name: Name for the new voice
raw_request: Raw FastAPI request

Returns:
JSON response with voice information
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")

try:
# Validate required parameters
if not consent:
return base(raw_request).create_error_response(message="consent is required")
if not name:
return base(raw_request).create_error_response(message="name is required")

# Upload the voice
result = await handler.upload_voice(audio_sample, consent, name)

return JSONResponse(content={
"success": True,
"voice": result
})
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

There are existing unit tests for Speech serving, but this PR adds a new POST /v1/audio/voices endpoint and new behavior for Base requests using voice without ref_audio (uploaded voices). Add tests that cover: successful upload (metadata persisted), duplicate name handling, file size/type validation, and that a subsequent Base request with voice=<uploaded> works without ref_audio.

Copilot uses AI. Check for mistakes.
Comment on lines 839 to 886
@router.post(
"/v1/audio/voices",
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def upload_voice(
raw_request: Request,
audio_sample: UploadFile = File(...),
consent: str = Form(...),
name: str = Form(...),
):
"""Upload a new voice sample for voice cloning.

Uploads an audio file that can be used as a reference for voice cloning
in Base task TTS requests. The voice can then be referenced by name
in subsequent TTS requests.

Args:
audio_sample: Audio file (max 10MB)
consent: Consent recording ID
name: Name for the new voice
raw_request: Raw FastAPI request

Returns:
JSON response with voice information
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")

try:
# Validate required parameters
if not consent:
return base(raw_request).create_error_response(message="consent is required")
if not name:
return base(raw_request).create_error_response(message="name is required")

# Upload the voice
result = await handler.upload_voice(audio_sample, consent, name)

return JSONResponse(content={
"success": True,
"voice": result
})

Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

POST /v1/audio/voices persists uploaded files under SPEECH_VOICE_SAMPLES with no retention/cleanup or quota beyond per-file size. Over time this can fill disk (especially with many uploads). Consider adding a configurable max number/total size of uploaded voices per server instance, and/or a delete endpoint / periodic cleanup policy.

Copilot uses AI. Check for mistakes.
Comment on lines +240 to +233
except Exception as e:
logger.error(f"Failed to save cache for speaker {speaker}: {e}")
# Update status to failed
self.update_metadata_cache_info(speaker, Path(""), "failed")
return False
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

In the exception path of save_voice_cache, update_metadata_cache_info is called with Path(""), which stringifies to "." and gets persisted as cache_file. This leaves misleading metadata and can point to a directory. Prefer clearing cache_file/cache_generated_at (e.g., write null/empty) when status is "failed", or update only the status field on failures.

Copilot uses AI. Check for mistakes.
Comment on lines 157 to 163

# Check cache status and file existence
if cache_file_path and cache_file_path.exists() and cache_status == "ready":
logger.info(f"Using cached voice clone prompt for speaker: {speaker}")

# Load cached VoiceClonePromptItem
cached_items = torch.load(cache_file_path, map_location="cpu", weights_only=False)
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

load_cached_voice_prompt loads cache_file from metadata.json and passes it directly into torch.load(..., weights_only=False). If metadata.json is tampered with, this can point outside the intended directory and torch.load can execute arbitrary code via pickle. Validate that cache_file_path resolves within SPEECH_VOICE_SAMPLES (and ideally only allow .pt files you created), and consider switching to a safer serialization format (e.g., store plain tensors in a JSON + safetensors/npz) so untrusted paths can’t trigger code execution.

Suggested change
# Check cache status and file existence
if cache_file_path and cache_file_path.exists() and cache_status == "ready":
logger.info(f"Using cached voice clone prompt for speaker: {speaker}")
# Load cached VoiceClonePromptItem
cached_items = torch.load(cache_file_path, map_location="cpu", weights_only=False)
# Validate cache file path before loading with torch.load
resolved_cache_path: Optional[Path] = None
if cache_file_path is not None:
try:
resolved_cache_path = cache_file_path.resolve()
except Exception as exc:
logger.warning(
f"Failed to resolve cache file path for speaker {speaker}: {exc}"
)
return None
# If a base directory for speech voice samples is defined, ensure the
# resolved cache path is within that directory to prevent directory traversal.
base_dir = globals().get("SPEECH_VOICE_SAMPLES")
if base_dir is not None:
try:
base_dir_path = Path(base_dir).resolve()
resolved_cache_path.relative_to(base_dir_path)
except Exception:
logger.warning(
f"Cache file path for speaker {speaker} is outside of the allowed "
f"directory; refusing to load."
)
return None
# Optionally restrict to known-safe extension (e.g., .pt)
if resolved_cache_path.suffix != ".pt":
logger.warning(
f"Unexpected cache file extension for speaker {speaker}: "
f"{resolved_cache_path.suffix!r}; refusing to load."
)
return None
# Check cache status and file existence
if resolved_cache_path and resolved_cache_path.exists() and cache_status == "ready":
logger.info(f"Using cached voice clone prompt for speaker: {speaker}")
# Load cached VoiceClonePromptItem
cached_items = torch.load(resolved_cache_path, map_location="cpu", weights_only=False)

Copilot uses AI. Check for mistakes.
Comment on lines 131 to 137
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:
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

_save_uploaded_speakers overwrites metadata.json with the in-memory self.uploaded_speakers dict. Since cache generation updates metadata.json from VoiceCacheManager independently, this write can silently clobber cache_status/cache_file/cache_generated_at (and concurrent uploads can lose updates/corrupt JSON). Use an atomic write + file lock, and merge/reload the on-disk metadata before writing so cache updates aren’t lost.

Copilot uses AI. Check for mistakes.
Comment on lines 381 to 391

# If voice is an uploaded speaker and no ref_audio provided, auto-set it
if request.voice.lower() in self.uploaded_speakers and request.ref_audio is None:
audio_data = self._get_uploaded_audio_data(request.voice)
if audio_data:
params["ref_audio"] = [audio_data]
params["x_vector_only_mode"] = [True]
logger.info(f"Auto-set ref_audio for uploaded voice: {request.voice}")
else:
raise ValueError(f"Audio file for uploaded voice '{request.voice}' is missing or corrupted")

Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

For uploaded voices, _build_tts_params base64-encodes the audio file on every request when ref_audio isn’t provided, even though generate_voice_clone can use the on-disk cache after the first run. This adds avoidable CPU/memory overhead (up to 10MB read + encode per request). Consider checking cache_status == "ready" (and cache file existence) in metadata and skipping the ref_audio injection when the cache is already available.

Suggested change
# If voice is an uploaded speaker and no ref_audio provided, auto-set it
if request.voice.lower() in self.uploaded_speakers and request.ref_audio is None:
audio_data = self._get_uploaded_audio_data(request.voice)
if audio_data:
params["ref_audio"] = [audio_data]
params["x_vector_only_mode"] = [True]
logger.info(f"Auto-set ref_audio for uploaded voice: {request.voice}")
else:
raise ValueError(f"Audio file for uploaded voice '{request.voice}' is missing or corrupted")
# If voice is an uploaded speaker and no ref_audio provided, auto-set it
if request.voice.lower() in self.uploaded_speakers and request.ref_audio is None:
speaker_key = request.voice.lower()
metadata = self.uploaded_speakers.get(speaker_key)
# If a cache is already ready on disk for this uploaded voice, rely on it
cache_status = None
cache_path = None
if isinstance(metadata, dict):
cache_status = metadata.get("cache_status")
cache_path = metadata.get("cache_path")
if cache_status == "ready" and cache_path and os.path.exists(cache_path):
# Cache is ready; no need to inject ref_audio (generate_voice_clone will use cache)
logger.info(
"Using existing cache for uploaded voice '%s'; skipping ref_audio injection",
request.voice,
)
else:
audio_data = self._get_uploaded_audio_data(request.voice)
if audio_data:
params["ref_audio"] = [audio_data]
params["x_vector_only_mode"] = [True]
logger.info(f"Auto-set ref_audio for uploaded voice: {request.voice}")
else:
raise ValueError(
f"Audio file for uploaded voice '{request.voice}' is missing or corrupted"
)

Copilot uses AI. Check for mistakes.
@zhaotyer zhaotyer force-pushed the add_custom_voice branch 3 times, most recently from 7c30058 to e5140f1 Compare February 9, 2026 12:56
tianyi added 4 commits February 9, 2026 21:01
- Prevent path traversal attacks in filename handling
- Remove file_path from API responses
- Update documentation
- Improve validation logic
Signed-off-by: tianyi <tianyi.zhao@transwarp.io>
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