Skip to content

feat: Add OpenAI Custom Voice support for stable Audio API#2744

Draft
snopoke wants to merge 8 commits into
mainfrom
sk/openai-custom-voices
Draft

feat: Add OpenAI Custom Voice support for stable Audio API#2744
snopoke wants to merge 8 commits into
mainfrom
sk/openai-custom-voices

Conversation

@snopoke

@snopoke snopoke commented Feb 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Migrates OpenAI audio integration from the deprecated beta Voice Engine API (/v1/audio/synthesize) to the current stable OpenAI Audio API (/v1/audio/speech) with custom voice support.

  • Add new OpenAICustomVoice service type alongside existing OpenAIVoiceEngine
  • Implement two-phase voice creation workflow (consent → voice creation)
  • Store OpenAI voice_id and consent_id in new config JSONField
  • Add voice management views and UI for creating/managing custom voices
  • Support consent phrases in 4 languages (en, es, de, fr)

Key Changes

Models & Migration:

  • New OpenAICustomVoice service type in SyntheticVoice
  • Added config JSONField for storing voice metadata
  • Helper methods: get_openai_voice_id(), get_openai_consent_id()

API Client:

  • New OpenAICustomVoiceClient for consent/voice CRUD operations
  • Uses httpx for API requests (project standard)
  • Dataclasses for VoiceConsent and CustomVoice responses

Speech Service:

  • New OpenAICustomVoiceSpeechService using stable /v1/audio/speech API
  • Supports instructions parameter for gpt-4o-mini-tts model
  • Full transcription support via Whisper API

Views & UI:

  • Voice management views: list, create, delete voices and consents
  • DaisyUI templates with proper i18n support
  • Integrated with existing team permissions

Test plan

  • Unit tests for API client (18 tests passing)
  • Unit tests for speech service
  • Django system check passes
  • Manual testing with OpenAI API (requires custom voice access)

Notes

  • Custom voice feature requires OpenAI sales contact for enablement
  • Old OpenAIVoiceEngine remains excluded by default (feature flag)
  • Max 20 custom voices per OpenAI organization

🤖 Generated with Claude Code

snopoke and others added 4 commits February 2, 2026 15:05
Phase 1 of OpenAI custom voice migration:
- Add OpenAICustomVoice to SyntheticVoice.SERVICES and TEAM_SCOPED_SERVICES
- Add config JSONField to SyntheticVoice for storing voice_id and consent_id
- Add helper methods get_openai_voice_id() and get_openai_consent_id()
- Add openai_custom_voice to VoiceProviderType enum
- Increase service field max_length from 17 to 20 chars
- Update model tests for new team-scoped service
Phase 2 of OpenAI custom voice migration:
- Add OpenAICustomVoiceClient with consent and voice management APIs
- Add VoiceConsent and CustomVoice dataclasses for API responses
- Add OpenAICustomVoiceSpeechService using stable /v1/audio/speech API
- Support voice ID-based synthesis with optional instructions
- Add comprehensive tests for API client and speech service
Phase 3 of OpenAI custom voice implementation:
- Add OpenAICustomVoiceFileFormset with file extension/size validation
- Add OpenAICustomVoiceConfigForm extending OpenAIConfigForm
- Add get_custom_voice_client method to VoiceProvider model
- Add tests for VoiceProvider client method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Phase 4 & 5 of OpenAI custom voice implementation:
- Add views for listing, creating, and deleting custom voices
- Add views for managing voice consents
- Add URL patterns for custom voice management endpoints
- Create templates for voice and consent management UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@snopoke snopoke marked this pull request as draft February 2, 2026 14:15
@coderabbitai

coderabbitai Bot commented Feb 2, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR implements comprehensive OpenAI Custom Voice API support across the platform. It adds a new OpenAICustomVoice service type to the SyntheticVoice model via database migration, introduces an OpenAICustomVoiceClient class for managing voice consents and custom voices via HTTP requests to OpenAI's API, implements OpenAICustomVoiceSpeechService for audio synthesis with custom voices, and adds form validation with file size and type constraints. Five new view functions handle consent creation, voice creation, and lifecycle management, supported by four corresponding HTML templates for user interactions. Database and provider models are extended to route custom voice operations through new dedicated components. Comprehensive test coverage validates all new functionality.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • SmittieC
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.61% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the main change: adding OpenAI Custom Voice support for the stable Audio API, which aligns with the PR's primary objective of migrating from deprecated Voice Engine to stable Audio API.
Description check ✅ Passed The PR description is well-structured and covers the required template sections: technical description of the migration with key changes, migration status checkbox (backwards compatible migrations), demo/testing plan, and changelog requirement checkbox. All critical information is present and organized.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/service_providers/views.py`:
- Around line 490-503: The current flow calls provider.get_custom_voice_client()
and client.delete_voice(openai_voice_id) but always proceeds to call
voice.delete() and messages.success even when the remote delete failed; change
the logic in the view so that after calling client.delete_voice(openai_voice_id)
you only delete the local DB record (voice.delete()) and show messages.success
when the remote deletion either succeeds or returns a not-found/404; if the
remote call raises any other exception, do not delete the local voice, log the
error via log.error/warning, and surface an error message to the user (use
messages.error) so they know remote cleanup failed; if openai_voice_id is falsy,
continue to delete locally as before. Ensure you reference
provider.get_custom_voice_client, client.delete_voice, voice.delete, and
messages.success/messages.error when locating the code to modify.
- Around line 417-441: The code calls provider.get_custom_voice_client() and
client.create_voice(...) before persisting SyntheticVoice with
SyntheticVoice.objects.create(...), risking orphaned remote voices if the DB
insert fails; modify the flow to first check for existing SyntheticVoice
duplicates (by voice_name/consent_id/service/voice_provider) and only call
client.create_voice when none exists, or if you must create remotely first, wrap
SyntheticVoice.objects.create in a try/except that on exception calls
client.delete_voice(voice.id) (or the provider's equivalent) to clean up the
remote voice and then re-raise the exception; ensure you reference
provider.get_custom_voice_client, client.create_voice, client.delete_voice, and
SyntheticVoice.objects.create when making the changes.

In `@templates/service_providers/custom_voice/list_voices.html`:
- Line 5: The wrapper div using the Tailwind Typography class "prose" (in
templates/service_providers/custom_voice/list_voices.html) lacks dark-mode
inversion; update the class attribute on the <div class="prose max-w-none"> to
include dark:prose-invert (or the DaisyUI equivalent) so typography inverts
correctly in dark theme and ensures all text remains high-contrast across
themes.
🧹 Nitpick comments (4)
apps/service_providers/forms.py (1)

115-123: Annotate class-level file limits as ClassVar

accepted_file_types and max_file_size_mb are class-level configs and trigger Ruff RUF012. Mark them as ClassVar (or use tuples) to avoid accidental mutation.

♻️ Suggested fix
+from typing import ClassVar
@@
-class OpenAICustomVoiceFileFormset(BaseFileFormSet):
+class OpenAICustomVoiceFileFormset(BaseFileFormSet):
@@
-    accepted_file_types = ["mp3", "wav", "ogg", "aac", "flac", "webm", "mp4", "mpeg"]
-    max_file_size_mb = 10
+    accepted_file_types: ClassVar[tuple[str, ...]] = (
+        "mp3",
+        "wav",
+        "ogg",
+        "aac",
+        "flac",
+        "webm",
+        "mp4",
+        "mpeg",
+    )
+    max_file_size_mb: ClassVar[int] = 10
apps/experiments/models.py (1)

476-484: Make TEAM_SCOPED_SERVICES immutable

A mutable class list can be changed at runtime and triggers Ruff RUF012. Prefer a tuple + ClassVar.

♻️ Suggested fix
-from typing import Self
+from typing import ClassVar, Self
@@
-    TEAM_SCOPED_SERVICES = [OpenAIVoiceEngine, OpenAICustomVoice]
+    TEAM_SCOPED_SERVICES: ClassVar[tuple[str, ...]] = (OpenAIVoiceEngine, OpenAICustomVoice)
apps/service_providers/views.py (1)

261-286: Silence unused team_slug parameters (Ruff ARG001)

These views don’t use team_slug; rename to _team_slug (or add del team_slug) to satisfy lint without changing the URL signature.

♻️ Suggested fix
-def list_custom_voice_consents(request, team_slug: str, pk: int):
+def list_custom_voice_consents(request, _team_slug: str, pk: int):
@@
-def list_custom_voices(request, team_slug: str, pk: int):
+def list_custom_voices(request, _team_slug: str, pk: int):

Also applies to: 355-390

apps/service_providers/openai_custom_voice.py (1)

58-74: Mark CONSENT_PHRASES as ClassVar

The class-level dict is mutable and triggers Ruff RUF012. Annotate it as ClassVar (or make it immutable) to prevent accidental mutation.

♻️ Suggested fix
+from typing import ClassVar
@@
-    CONSENT_PHRASES = {
+    CONSENT_PHRASES: ClassVar[dict[str, str]] = {
         "en": (
             "I am the owner of this voice and I consent to OpenAI using this voice to create a synthetic voice model."
         ),

Comment on lines +417 to +441
try:
client = provider.get_custom_voice_client()
voice = client.create_voice(
name=voice_name,
consent_id=consent_id,
audio_sample_file=audio_sample.file,
filename=audio_sample.name,
)

# Create SyntheticVoice record
SyntheticVoice.objects.create(
name=voice_name,
neural=True,
language="",
language_code="",
gender="",
service=SyntheticVoice.OpenAICustomVoice,
voice_provider=provider,
config={
"voice_id": voice.id,
"consent_id": consent_id,
"created_at": voice.created_at,
"model": "gpt-4o-mini-tts",
},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid orphaned OpenAI voices if DB insert fails

The remote voice is created before the local SyntheticVoice. If the DB insert fails (unique constraint/validation), the remote voice remains and counts toward the org limit. Consider checking for duplicates before the API call or deleting the remote voice on failure.

🛡️ Suggested cleanup on DB failure
-from django.db import transaction
+from django.db import IntegrityError, transaction
@@
-                voice = client.create_voice(
+                voice = client.create_voice(
                     name=voice_name,
                     consent_id=consent_id,
                     audio_sample_file=audio_sample.file,
                     filename=audio_sample.name,
                 )
 
-                # Create SyntheticVoice record
-                SyntheticVoice.objects.create(
-                    name=voice_name,
-                    neural=True,
-                    language="",
-                    language_code="",
-                    gender="",
-                    service=SyntheticVoice.OpenAICustomVoice,
-                    voice_provider=provider,
-                    config={
-                        "voice_id": voice.id,
-                        "consent_id": consent_id,
-                        "created_at": voice.created_at,
-                        "model": "gpt-4o-mini-tts",
-                    },
-                )
+                try:
+                    SyntheticVoice.objects.create(
+                        name=voice_name,
+                        neural=True,
+                        language="",
+                        language_code="",
+                        gender="",
+                        service=SyntheticVoice.OpenAICustomVoice,
+                        voice_provider=provider,
+                        config={
+                            "voice_id": voice.id,
+                            "consent_id": consent_id,
+                            "created_at": voice.created_at,
+                            "model": "gpt-4o-mini-tts",
+                        },
+                    )
+                except IntegrityError:
+                    client.delete_voice(voice.id)
+                    raise
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
client = provider.get_custom_voice_client()
voice = client.create_voice(
name=voice_name,
consent_id=consent_id,
audio_sample_file=audio_sample.file,
filename=audio_sample.name,
)
# Create SyntheticVoice record
SyntheticVoice.objects.create(
name=voice_name,
neural=True,
language="",
language_code="",
gender="",
service=SyntheticVoice.OpenAICustomVoice,
voice_provider=provider,
config={
"voice_id": voice.id,
"consent_id": consent_id,
"created_at": voice.created_at,
"model": "gpt-4o-mini-tts",
},
)
try:
client = provider.get_custom_voice_client()
voice = client.create_voice(
name=voice_name,
consent_id=consent_id,
audio_sample_file=audio_sample.file,
filename=audio_sample.name,
)
# Create SyntheticVoice record
try:
SyntheticVoice.objects.create(
name=voice_name,
neural=True,
language="",
language_code="",
gender="",
service=SyntheticVoice.OpenAICustomVoice,
voice_provider=provider,
config={
"voice_id": voice.id,
"consent_id": consent_id,
"created_at": voice.created_at,
"model": "gpt-4o-mini-tts",
},
)
except IntegrityError:
client.delete_voice(voice.id)
raise
🤖 Prompt for AI Agents
In `@apps/service_providers/views.py` around lines 417 - 441, The code calls
provider.get_custom_voice_client() and client.create_voice(...) before
persisting SyntheticVoice with SyntheticVoice.objects.create(...), risking
orphaned remote voices if the DB insert fails; modify the flow to first check
for existing SyntheticVoice duplicates (by
voice_name/consent_id/service/voice_provider) and only call client.create_voice
when none exists, or if you must create remotely first, wrap
SyntheticVoice.objects.create in a try/except that on exception calls
client.delete_voice(voice.id) (or the provider's equivalent) to clean up the
remote voice and then re-raise the exception; ensure you reference
provider.get_custom_voice_client, client.create_voice, client.delete_voice, and
SyntheticVoice.objects.create when making the changes.

Comment thread apps/service_providers/views.py Outdated
Comment on lines +490 to +503
try:
# Delete from OpenAI first if we have a voice_id
if openai_voice_id:
client = provider.get_custom_voice_client()
try:
client.delete_voice(openai_voice_id)
except Exception as e:
log.warning(f"Could not delete voice from OpenAI: {e}")
# Continue to delete from DB even if OpenAI deletion fails

# Delete from database
voice.delete()
messages.success(request, f"Voice '{voice_name}' deleted successfully")
except Exception as e:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t report success when remote deletion fails

If the OpenAI delete call errors, the code still deletes the DB record and shows success. This can leave an orphaned remote voice and mislead users. Consider only deleting locally on remote success (or 404) and surface a warning otherwise.

🛠️ Suggested flow
-        if openai_voice_id:
-            client = provider.get_custom_voice_client()
-            try:
-                client.delete_voice(openai_voice_id)
-            except Exception as e:
-                log.warning(f"Could not delete voice from OpenAI: {e}")
-                # Continue to delete from DB even if OpenAI deletion fails
-
-        # Delete from database
-        voice.delete()
-        messages.success(request, f"Voice '{voice_name}' deleted successfully")
+        deleted_remote = True
+        if openai_voice_id:
+            client = provider.get_custom_voice_client()
+            try:
+                deleted_remote = client.delete_voice(openai_voice_id)
+            except Exception as e:
+                log.warning(f"Could not delete voice from OpenAI: {e}")
+                deleted_remote = False
+
+        if deleted_remote:
+            voice.delete()
+            messages.success(request, f"Voice '{voice_name}' deleted successfully")
+        else:
+            messages.error(
+                request,
+                "OpenAI deletion failed; the voice was not removed locally so you can retry.",
+            )
🧰 Tools
🪛 Ruff (0.14.14)

[warning] 496-496: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@apps/service_providers/views.py` around lines 490 - 503, The current flow
calls provider.get_custom_voice_client() and
client.delete_voice(openai_voice_id) but always proceeds to call voice.delete()
and messages.success even when the remote delete failed; change the logic in the
view so that after calling client.delete_voice(openai_voice_id) you only delete
the local DB record (voice.delete()) and show messages.success when the remote
deletion either succeeds or returns a not-found/404; if the remote call raises
any other exception, do not delete the local voice, log the error via
log.error/warning, and surface an error message to the user (use messages.error)
so they know remote cleanup failed; if openai_voice_id is falsy, continue to
delete locally as before. Ensure you reference provider.get_custom_voice_client,
client.delete_voice, voice.delete, and messages.success/messages.error when
locating the code to modify.

{% load i18n %}

{% block app %}
<div class="prose max-w-none">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add dark‑mode typography for the prose container

Tailwind Typography’s prose class doesn’t invert in dark themes, so text can become low‑contrast. Add dark:prose-invert (or a DaisyUI equivalent) on the wrapper.

As per coding guidelines, Ensure all UI components support both light and dark modes for theme compatibility.

✅ Suggested fix
-<div class="prose max-w-none">
+<div class="prose max-w-none dark:prose-invert">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div class="prose max-w-none">
<div class="prose max-w-none dark:prose-invert">
🤖 Prompt for AI Agents
In `@templates/service_providers/custom_voice/list_voices.html` at line 5, The
wrapper div using the Tailwind Typography class "prose" (in
templates/service_providers/custom_voice/list_voices.html) lacks dark-mode
inversion; update the class attribute on the <div class="prose max-w-none"> to
include dark:prose-invert (or the DaisyUI equivalent) so typography inverts
correctly in dark theme and ensures all text remains high-contrast across
themes.

@codecov-commenter

codecov-commenter commented Feb 2, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
2192 1 2191 2
View the top 1 failed test(s) by shortest run time
apps/web/tests/test_missing_migrations.py::PendingMigrationsTests::test_no_pending_migrations
Stack Traces | 1.06s run time
.../web/tests/test_missing_migrations.py:18: in test_no_pending_migrations
    raise AssertionError("Pending migrations:\n" + out.getvalue()) from None
E   AssertionError: Pending migrations:
E   Migrations for 'service_providers':
E     .../service_providers/migrations/0045_alter_voiceprovider_type.py
E       ~ Alter field type on voiceprovider

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

snopoke and others added 4 commits March 13, 2026 10:47
Extract BaseOpenAISpeechService to eliminate duplicated _client,
_transcribe_audio, and config fields across three OpenAI speech
services. Refactor views to use Django Form classes instead of manual
POST extraction, adding server-side file validation and language
whitelist checking. Sanitize API error messages shown to users, add
CustomVoiceConfig TypedDict for typed config access, and add view
tests covering create, delete (including two-phase OpenAI+DB), and
error paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a "Manage Voices" action button (microphone icon) to the voice
provider table rows, visible only for OpenAI Custom Voice providers.
This links directly to the custom voice management page, making the
feature discoverable from the team settings UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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