Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.12 on 2026-03-13 09:19

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('experiments', '0131_drop_llm_provider_columns'),
]

operations = [
migrations.AddField(
model_name='syntheticvoice',
name='config',
field=models.JSONField(blank=True, default=dict, help_text='Additional configuration for the voice (e.g., OpenAI voice_id)'),
),
migrations.AlterField(
model_name='syntheticvoice',
name='service',
field=models.CharField(choices=[('AWS', 'AWS'), ('Azure', 'Azure'), ('OpenAI', 'OpenAI'), ('OpenAIVoiceEngine', 'OpenAIVoiceEngine'), ('OpenAICustomVoice', 'OpenAICustomVoice')], help_text='The service this voice is from', max_length=20),
),
]
65 changes: 63 additions & 2 deletions apps/experiments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,17 @@ def _get_version_details(self) -> VersionDetails:
)


from typing import NotRequired, TypedDict # noqa: E402


class CustomVoiceConfig(TypedDict):
voice_id: str
consent_id: str
model: str
instructions: NotRequired[str]
created_at: NotRequired[int]


@audit_fields(*model_audit_fields.SYNTHETIC_VOICE_FIELDS, audit_special_queryset_writes=True)
class SyntheticVoice(BaseModel):
"""
Expand All @@ -377,14 +388,16 @@ class SyntheticVoice(BaseModel):
Azure = "Azure"
OpenAI = "OpenAI"
OpenAIVoiceEngine = "OpenAIVoiceEngine"
OpenAICustomVoice = "OpenAICustomVoice"

SERVICES = (
("AWS", AWS),
("Azure", Azure),
("OpenAI", OpenAI),
("OpenAIVoiceEngine", OpenAIVoiceEngine),
("OpenAICustomVoice", OpenAICustomVoice),
)
TEAM_SCOPED_SERVICES = [OpenAIVoiceEngine]
TEAM_SCOPED_SERVICES = [OpenAIVoiceEngine, OpenAICustomVoice]

objects = SyntheticVoiceObjectManager()
name = models.CharField(
Expand All @@ -400,12 +413,15 @@ class SyntheticVoice(BaseModel):
null=False, blank=True, choices=GENDERS, max_length=14, help_text="The gender of this voice"
)
service = models.CharField(
null=False, blank=False, choices=SERVICES, max_length=17, help_text="The service this voice is from"
null=False, blank=False, choices=SERVICES, max_length=20, help_text="The service this voice is from"
)
voice_provider = models.ForeignKey(
"service_providers.VoiceProvider", verbose_name=gettext("Team"), on_delete=models.CASCADE, null=True
)
file = models.ForeignKey("files.File", null=True, on_delete=models.SET_NULL)
config = models.JSONField(
default=dict, blank=True, help_text="Additional configuration for the voice (e.g., OpenAI voice_id)"
)

class Meta:
ordering = ["name"]
Expand All @@ -424,6 +440,51 @@ def __str__(self):
display_str = f"{self.language}, {display_str}"
return display_str

def get_custom_voice_config(self) -> CustomVoiceConfig | None:
"""Parse config as CustomVoiceConfig for custom voices."""
if self.service == self.OpenAICustomVoice and self.config:
return CustomVoiceConfig(**self.config)
return None

def get_openai_voice_id(self) -> str | None:
"""Extract OpenAI voice ID from config for custom voices."""
cfg = self.get_custom_voice_config()
return cfg["voice_id"] if cfg else None

def get_openai_consent_id(self) -> str | None:
"""Extract OpenAI consent ID from config for custom voices."""
cfg = self.get_custom_voice_config()
return cfg["consent_id"] if cfg else None

@classmethod
def create_custom_voice(
cls,
name: str,
voice_provider,
voice_id: str,
consent_id: str,
model: str = "gpt-4o-mini-tts",
created_at: int | None = None,
) -> SyntheticVoice:
"""Factory method for creating custom voice records with proper config structure."""
config: CustomVoiceConfig = {
"voice_id": voice_id,
"consent_id": consent_id,
"model": model,
}
if created_at is not None:
config["created_at"] = created_at
return cls.objects.create(
name=name,
neural=True,
language="",
language_code="",
gender="",
service=cls.OpenAICustomVoice,
voice_provider=voice_provider,
config=config,
)

@staticmethod
def get_for_team(team: Team, exclude_services=None) -> list[SyntheticVoice]:
"""Returns a queryset for this team comprising of all general synthetic voice records and those exclusive
Expand Down
3 changes: 2 additions & 1 deletion apps/experiments/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ def experiment_session():
class TestSyntheticVoice:
@django_db_with_data()
def test_team_scoped_services(self):
assert [SyntheticVoice.OpenAIVoiceEngine] == SyntheticVoice.TEAM_SCOPED_SERVICES
expected = [SyntheticVoice.OpenAIVoiceEngine, SyntheticVoice.OpenAICustomVoice]
assert expected == SyntheticVoice.TEAM_SCOPED_SERVICES

@django_db_with_data()
def test_get_for_team_returns_all_general_services(self):
Expand Down
124 changes: 123 additions & 1 deletion apps/service_providers/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,129 @@ class OpenAIVoiceEngineConfigForm(OpenAIConfigForm):
file_formset_form = OpenAIVoiceEngineFileFormset


class OpenAICustomVoiceFileFormset(BaseFileFormSet):
"""
File formset for OpenAI Custom Voice audio samples.
Validates file extension, size, and provides guidance on duration limits.
"""

accepted_file_types = ["mp3", "wav", "ogg", "aac", "flac", "webm", "mp4", "mpeg"]
max_file_size_mb = 10

def clean(self) -> None:
invalid_extensions = set()
oversized_files = []

for _key, in_memory_file in self.files.items():
# Validate file extension
file_extension = in_memory_file.name.rsplit(".", 1)[-1].lower()
if file_extension not in self.accepted_file_types:
invalid_extensions.add(f".{file_extension}")

# Validate file size
file_size_mb = in_memory_file.size / (1024 * 1024)
if file_size_mb > self.max_file_size_mb:
oversized_files.append(f"{in_memory_file.name} ({file_size_mb:.1f}MB)")

errors = []
if invalid_extensions:
valid_types = ", ".join(f".{t}" for t in self.accepted_file_types)
errors.append(f"File extensions not supported: {', '.join(invalid_extensions)}. Accepted: {valid_types}")

if oversized_files:
errors.append(f"Files exceed {self.max_file_size_mb}MB limit: {', '.join(oversized_files)}")

if errors:
raise forms.ValidationError(errors)

return super().clean()


class OpenAICustomVoiceConfigForm(OpenAIConfigForm):
"""
Configuration form for OpenAI Custom Voice provider.
Extends OpenAIConfigForm with file upload support for voice samples.
"""

allow_file_upload = True
file_formset_form = OpenAICustomVoiceFileFormset


ACCEPTED_AUDIO_TYPES = ["mp3", "wav", "ogg", "aac", "flac", "webm", "mp4", "mpeg"]
MAX_AUDIO_FILE_SIZE_MB = 10


def _validate_audio_file(file, field_name: str) -> list[str]:
"""Validate an uploaded audio file for extension and size."""
errors = []
if file:
ext = file.name.rsplit(".", 1)[-1].lower() if "." in file.name else ""
if ext not in ACCEPTED_AUDIO_TYPES:
valid = ", ".join(f".{t}" for t in ACCEPTED_AUDIO_TYPES)
errors.append(f"{field_name}: unsupported file type '.{ext}'. Accepted: {valid}")
file_size_mb = file.size / (1024 * 1024)
if file_size_mb > MAX_AUDIO_FILE_SIZE_MB:
errors.append(f"{field_name}: file exceeds {MAX_AUDIO_FILE_SIZE_MB}MB limit ({file_size_mb:.1f}MB)")
return errors


class VoiceConsentForm(forms.Form):
"""Form for uploading a voice consent recording."""

consent_name = forms.CharField(
max_length=255,
label=_("Consent Name"),
help_text=_("A name to identify this consent recording"),
)
consent_language = forms.ChoiceField(
label=_("Language"),
help_text=_("The language of the consent phrase you recorded"),
)
consent_recording = forms.FileField(
label=_("Consent Recording"),
help_text=_("Upload your recording of the consent phrase"),
)

def __init__(self, *args, supported_languages=None, **kwargs):
super().__init__(*args, **kwargs)
if supported_languages:
self.fields["consent_language"].choices = supported_languages

def clean_consent_recording(self):
file = self.cleaned_data.get("consent_recording")
if file:
errors = _validate_audio_file(file, "Consent recording")
if errors:
raise forms.ValidationError(errors)
return file


class CustomVoiceCreationForm(forms.Form):
"""Form for creating a custom voice from an audio sample."""

voice_name = forms.CharField(
max_length=255,
label=_("Voice Name"),
help_text=_("A descriptive name for this voice"),
)
consent_id = forms.CharField(
label=_("Consent Recording"),
help_text=_("The consent recording must be from the same voice actor"),
)
audio_sample = forms.FileField(
label=_("Audio Sample"),
help_text=_("30 seconds or less of the voice you want to clone"),
)

def clean_audio_sample(self):
file = self.cleaned_data.get("audio_sample")
if file:
errors = _validate_audio_file(file, "Audio sample")
if errors:
raise forms.ValidationError(errors)
return file


class AzureOpenAIConfigForm(ObfuscatingMixin, ProviderTypeConfigForm):
obfuscate_fields = ["openai_api_key"]

Expand Down Expand Up @@ -288,7 +411,6 @@ class SlackMessagingConfigForm(ProviderTypeConfigForm):
slack_installation_id = forms.CharField(widget=forms.HiddenInput())

def get_slack_installation(self):

if team_id := self.initial.get("slack_team_id"):
return SlackInstallation.objects.filter(slack_team_id=team_id).first()

Expand Down
18 changes: 18 additions & 0 deletions apps/service_providers/migrations/0045_alter_voiceprovider_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-03-13 12:39

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('service_providers', '0044_update_openai_models'),
]

operations = [
migrations.AlterField(
model_name='voiceprovider',
name='type',
field=models.CharField(choices=[('aws', 'AWS Polly'), ('azure', 'Azure Text to Speech'), ('openai', 'OpenAI Text to Speech'), ('openaivoiceengine', 'OpenAI Voice Engine Text to Speech'), ('openaicustomvoice', 'OpenAI Custom Voice')], max_length=255),
),
]
25 changes: 24 additions & 1 deletion apps/service_providers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ class VoiceProviderType(models.TextChoices):
azure = "azure", _("Azure Text to Speech")
openai = "openai", _("OpenAI Text to Speech")
openai_voice_engine = "openaivoiceengine", _("OpenAI Voice Engine Text to Speech")
openai_custom_voice = "openaicustomvoice", _("OpenAI Custom Voice")

@property
def form_cls(self) -> type["ProviderTypeConfigForm"]:
Expand All @@ -276,6 +277,8 @@ def form_cls(self) -> type["ProviderTypeConfigForm"]:
return forms.OpenAIConfigForm
case VoiceProviderType.openai_voice_engine:
return forms.OpenAIVoiceEngineConfigForm
case VoiceProviderType.openai_custom_voice:
return forms.OpenAICustomVoiceConfigForm
raise Exception(f"No config form configured for {self}")

def get_speech_service(self, config: dict) -> "speech_service.SpeechService":
Expand All @@ -291,6 +294,8 @@ def get_speech_service(self, config: dict) -> "speech_service.SpeechService":
return speech_service.OpenAISpeechService(**config)
case VoiceProviderType.openai_voice_engine:
return speech_service.OpenAIVoiceEngineSpeechService(**config)
case VoiceProviderType.openai_custom_voice:
return speech_service.OpenAICustomVoiceSpeechService(**config)
except ValidationError as e:
raise ServiceProviderConfigError(self, str(e)) from e
raise ServiceProviderConfigError(self, "No voice service configured")
Expand All @@ -317,6 +322,24 @@ def get_speech_service(self) -> "speech_service.SpeechService":
config = {k: v for k, v in self.config.items() if v}
return self.type_enum.get_speech_service(config)

def get_custom_voice_client(self):
"""
Get OpenAI Custom Voice API client for voice management operations.
Only available for openai_custom_voice provider type.
"""
if self.type != VoiceProviderType.openai_custom_voice:
raise ValueError(f"Custom voice client not available for provider type: {self.type}")

from apps.service_providers.openai_custom_voice import ( # noqa: PLC0415 - lazy: optional provider dep
OpenAICustomVoiceClient,
)

return OpenAICustomVoiceClient(
api_key=self.config["openai_api_key"],
organization=self.config.get("openai_organization"),
base_url=self.config.get("openai_api_base"),
)

@transaction.atomic()
def add_files(self, files):
if self.type == VoiceProviderType.openai_voice_engine:
Expand Down Expand Up @@ -371,7 +394,7 @@ def add_file_url(self):

@transaction.atomic()
def delete(self): # ty: ignore[invalid-method-override]
if self.type == VoiceProviderType.openai_voice_engine:
if self.type in (VoiceProviderType.openai_voice_engine, VoiceProviderType.openai_custom_voice):
files_to_delete = self.get_files()
[f.delete() for f in files_to_delete]
return super().delete()
Expand Down
Loading
Loading