diff --git a/_docs/api/source/resources.rst b/_docs/api/source/resources.rst index 9b7393630..117256d46 100644 --- a/_docs/api/source/resources.rst +++ b/_docs/api/source/resources.rst @@ -299,7 +299,7 @@ geotag string yes* Latitude and longitude o is_geotagged boolean yes Whether the sound has geotag information. created string yes The date when the sound was uploaded (e.g. "2014-04-16T20:07:11.145"). license string yes The Creative Commons license under which the sound is available to you ("Attribution", "Attribution NonCommercial", "Creative Commons 0"). -ai_preference string yes The user preference regarding the use of the sound for training generative AI models ("freesound-cc-recommendation", "open-models", "open-noncommercial-models"). Please check `our help section about generative AI training preferences `_ for more information about the meaning of these values. +gen_ai_preference string yes The user preference regarding the use of the sound for training generative AI models ("no-additional-preferences", "open-source-models", "noncommercial-open-source-models", "no-gen-ai"). Please check `our help section about generative AI training preferences `_ for more information about the meaning of these values. type string yes The original type of the sound (wav, aif, aiff, ogg, mp3, m4a, or flac). channels integer yes The number of sound channels (mostly 1 or 2). filesize integer yes The size of the file in bytes. diff --git a/accounts/forms.py b/accounts/forms.py index 473a7b773..6418d55f2 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -361,29 +361,12 @@ class ProfileForm(forms.ModelForm): required=False, widget=forms.CheckboxInput(attrs={"class": "bw-checkbox"}), ) - ai_sound_usage_preference = forms.ChoiceField( - label=mark_safe( - '
I agree with my sounds being used to train generative AI models provided that:
' - ), - choices=AIPreference.AI_PREFERENCE_CHOICES, - required=False, - help_text=mark_safe( - '
Use the setting above to express a ' - "preference regarding the usage of your sounds for training generative Artificial Intelligence models. " - 'This preference applies to all your uploaded sounds. Please, read the ' - "Usage of my sounds for " - "training generative AI models help section to learn more about the details and implications of the available options.
" - ), - ) def __init__(self, request, *args, **kwargs): self.request = request initial_kwargs = { "username": request.user.username, } - ai_preference = request.user.profile.get_ai_preference() - if ai_preference: - initial_kwargs["ai_sound_usage_preference"] = ai_preference kwargs.update(initial=initial_kwargs) kwargs.update(dict(label_suffix="")) super().__init__(*args, **kwargs) @@ -507,6 +490,26 @@ def get_img_check_fields(self): return [self["about"], self["signature"], self["sound_signature"]] +class AIPreferenceForm(forms.Form): + ai_sound_usage_preference = forms.ChoiceField( + label="", + choices=AIPreference.AI_PREFERENCE_CHOICES, + widget=forms.RadioSelect(), + ) + opt_out_speech = forms.BooleanField( + label=mark_safe( + "Express my preference that sounds classified under the \"Speech > Solo speech\" BST category not be used for the purpose of training Gen AI models." + ), + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["ai_sound_usage_preference"].widget.attrs["class"] = "bw-radio" + self.ai_options_and_texts = AIPreference.AI_PREFERENCE_CHOICES_AND_EXPLANATION + self.fields["opt_out_speech"].widget.attrs["class"] = "bw-checkbox" + + class EmailResetForm(forms.Form): email = forms.EmailField(label="New email address", max_length=254) password = forms.CharField(label="Your password", widget=forms.PasswordInput) diff --git a/accounts/migrations/0043_aipreference_opt_out_speech_and_more.py b/accounts/migrations/0043_aipreference_opt_out_speech_and_more.py new file mode 100644 index 000000000..43f369962 --- /dev/null +++ b/accounts/migrations/0043_aipreference_opt_out_speech_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.27 on 2026-07-08 09:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0042_aipreference'), + ] + + operations = [ + migrations.AddField( + model_name='aipreference', + name='opt_out_speech', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='aipreference', + name='preference', + field=models.CharField(choices=[('no-additional-restrictions', 'No additional restrictions'), ('open-models', 'Open Models only'), ('open-noncommercial-models', 'Open Non-Commercial Models only'), ('no-gen-ai', 'No generative AI')], default='no-additional-restrictions'), + ), + ] diff --git a/accounts/migrations/0044_alter_aipreference_preference.py b/accounts/migrations/0044_alter_aipreference_preference.py new file mode 100644 index 000000000..9f1567383 --- /dev/null +++ b/accounts/migrations/0044_alter_aipreference_preference.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.27 on 2026-07-10 07:41 + +from django.db import migrations, models + + +def rename_aipreference_preferences(apps, schema_editor): + AIPreference = apps.get_model('accounts', 'AIPreference') + + preference_mapping = { + 'freesound-cc-recommendation': 'no-additional-preferences', + 'open-noncommercial-models': 'noncommercial-open-source-models', + 'open-models': 'open-source-models', + } + + for old_value, new_value in preference_mapping.items(): + AIPreference.objects.filter(preference=old_value).update(preference=new_value) + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0043_aipreference_opt_out_speech_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='aipreference', + name='preference', + field=models.CharField(choices=[('no-additional-preferences', 'No additional preferences'), ('open-source-models', 'Open Source Models only'), ('noncommercial-open-source-models', 'Non-Commercial Open Source Models only'), ('no-gen-ai', 'No generative AI')], default='no-additional-preferences'), + ), + migrations.RunPython( + rename_aipreference_preferences, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 5971487e4..b7ba1ce1d 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -695,17 +695,39 @@ def get_stats_for_profile_page(self): stats_from_db.update(stats_from_cache) return stats_from_db - def get_ai_preference(self, default_if_not_set=True): + def get_gen_ai_preference(self, default_if_not_set=True, category_code=None): + """ + Returns the user's generative AI preference. + If no preference is set, returns the default preference if default_if_not_set is True, otherwise None. + If "category_code" argument is specified, this method will consider if there should be any preference overrides + because of that. + """ try: - return self.user.ai_preference.preference + preference_object = self.user.ai_preference + if ( + category_code is not None + and preference_object.opt_out_speech is True + and category_code in settings.AI_OPT_OUT_SPEECH_TAXONOMY_CODES + ): + return settings.AI_PREF_NO_GEN_AI + return preference_object.preference except AIPreference.DoesNotExist: # If no preference is set, return the default one if default_if_not_set: - return AIPreference.DEFAULT_AI_PREFERENCE + return settings.DEFAULT_AI_PREFERENCE return None - def set_ai_preference(self, preference_value): - AIPreference.objects.update_or_create(user=self.user, defaults={"preference": preference_value}) + def set_gen_ai_preference(self, preference_value, opt_out_speech): + preference, _ = AIPreference.objects.update_or_create(user=self.user, defaults={"preference": preference_value}) + if opt_out_speech != preference.opt_out_speech: + preference.opt_out_speech = opt_out_speech + preference.save() + + def get_gen_ai_preference_opt_out_speech(self): + try: + return self.user.ai_preference.opt_out_speech + except AIPreference.DoesNotExist: + return settings.DEFAULT_OPT_OUT_SPEECH class Meta: ordering = ("-user__date_joined",) @@ -722,19 +744,36 @@ class GdprAcceptance(models.Model): class AIPreference(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="ai_preference") date_updated = models.DateTimeField(auto_now=True) - AI_PREFERENCE_CHOICES = ( - ( - "freesound-cc-recommendation", - "My sounds are used following Freesound's recommendations for interpreting Creative Commons licenses in a generative AI training context", - ), - ("open-models", "My sounds are used to train open source models that are freely available to the public"), - ( - "open-noncommercial-models", - "My sounds are used to train open source models that are freely available to the public and that do not allow a commercial use", - ), - ) - DEFAULT_AI_PREFERENCE = "freesound-cc-recommendation" - preference = models.CharField(choices=AI_PREFERENCE_CHOICES, default=DEFAULT_AI_PREFERENCE) + + AI_PREFERENCE_CHOICES_AND_EXPLANATION = [ + { + "code": settings.AI_PREF_NO_ADDITIONAL_PREFERENCES, + "label": "No additional preferences", + "explanation": """No additional statement, your sounds remain subject to the Creative Commons license terms selected for them. + Read here for more details.""", + }, + { + "code": settings.AI_PREF_OPEN_MODELS, + "label": "Open Source Models only", + "explanation": """You express a preference for your sounds to be used for the purpose of training Gen AI models only when the trained models are released as Open Source Models and made freely available to the public. + Read here for more details.""", + }, + { + "code": settings.AI_PREF_OPEN_NONCOMMERCIAL_MODELS, + "label": "Non-Commercial Open Source Models only", + "explanation": """You express a preference for your sounds to be used for the purpose of training Gen AI models only when the trained models are released as Open Source Models, made freely available to the public, and not trained in a commercial setting nor used for commercial purposes. + Read here for more details.""", + }, + { + "code": settings.AI_PREF_NO_GEN_AI, + "label": "No generative AI", + "explanation": """You express a preference that your sounds not be used for the purpose of training Gen AI models. Read here for more details.""", + }, + ] + + AI_PREFERENCE_CHOICES = [(item["code"], item["label"]) for item in AI_PREFERENCE_CHOICES_AND_EXPLANATION] + preference = models.CharField(choices=AI_PREFERENCE_CHOICES, default=settings.DEFAULT_AI_PREFERENCE) + opt_out_speech = models.BooleanField(default=settings.DEFAULT_OPT_OUT_SPEECH) class UserFlag(models.Model): diff --git a/accounts/tests/test_profile.py b/accounts/tests/test_profile.py index 98433e0fc..77f0fd3a5 100644 --- a/accounts/tests/test_profile.py +++ b/accounts/tests/test_profile.py @@ -36,7 +36,7 @@ import accounts.models from accounts.management.commands.process_email_bounces import decode_idna_email, process_message -from accounts.models import AIPreference, EmailBounce, EmailPreferenceType, UserEmailSetting +from accounts.models import EmailBounce, EmailPreferenceType, UserEmailSetting from accounts.views import handle_uploaded_image from forum.models import Forum, Post, Thread from geotags.models import GeoTag @@ -216,46 +216,41 @@ def test_edit_user_profile_ai_preference(self): user.profile.save() # Check that user does not start with any preference set - self.assertEqual(user.profile.get_ai_preference(default_if_not_set=False), None) + self.assertEqual(user.profile.get_gen_ai_preference(default_if_not_set=False), None) + self.assertEqual(user.profile.get_gen_ai_preference_opt_out_speech(), settings.DEFAULT_OPT_OUT_SPEECH) - # Check that "get_ai_preference" returns the default one when no preference is set and default_if_not_set is True (default) - self.assertEqual(user.profile.get_ai_preference(), AIPreference.DEFAULT_AI_PREFERENCE) + # Check that "get_gen_ai_preference" returns the default one when no preference is set and default_if_not_set is True (default) + self.assertEqual(user.profile.get_gen_ai_preference(), settings.DEFAULT_AI_PREFERENCE) # Now user edits preference in profile page self.client.force_login(user) - new_preference = "open-models" - self.client.post( - "/home/edit/", + new_preference = "open-source-models" + resp = self.client.post( + "/home/ai-preferences/", { - "profile-home_page": "http://www.example.com/", - "profile-username": "testuser", - "profile-about": "About test text", - "profile-signature": "Signature test text", - "profile-ui_theme_preference": "d", - "profile-ai_sound_usage_preference": new_preference, + "ai_sound_usage_preference": new_preference, + "opt_out_speech": True, }, ) # Check that new preference is set and that sounds were marked as dirty user = User.objects.select_related("profile").get(username="testuser") - self.assertEqual(user.profile.get_ai_preference(), new_preference) + self.assertEqual(user.profile.get_gen_ai_preference(), new_preference) + self.assertEqual(user.profile.get_gen_ai_preference_opt_out_speech(), True) self.assertEqual(Sound.objects.filter(user=user, is_index_dirty=True).count(), len(sounds)) # Now that there's an AI preference object already existing, try to change preference again and check that it works as expected - even_newer_preference = "freesound-cc-recommendation" + even_newer_preference = "no-additional-preferences" self.client.post( - "/home/edit/", + "/home/ai-preferences/", { - "profile-home_page": "http://www.example.com/", - "profile-username": "testuser", - "profile-about": "About test text", - "profile-signature": "Signature test text", - "profile-ui_theme_preference": "d", - "profile-ai_sound_usage_preference": even_newer_preference, + "ai_sound_usage_preference": even_newer_preference, + "opt_out_speech": False, }, ) user = User.objects.select_related("profile").get(username="testuser") - self.assertEqual(user.profile.get_ai_preference(), even_newer_preference) + self.assertEqual(user.profile.get_gen_ai_preference(), even_newer_preference) + self.assertEqual(user.profile.get_gen_ai_preference_opt_out_speech(), False) def test_edit_user_email_settings(self): EmailPreferenceType.objects.create(name="email", display_name="email") diff --git a/accounts/urls.py b/accounts/urls.py index b7eb8a63e..7ee98076d 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -86,6 +86,7 @@ path("", accounts.home, name="accounts-home"), path("edit/", accounts.edit, name="accounts-edit"), path("email-settings/", accounts.edit_email_settings, name="accounts-email-settings"), + path("ai-preferences/", accounts.edit_ai_preferences, name="accounts-ai-preferences"), path("delete/", accounts.delete, name="accounts-delete"), path("attribution/", accounts.attribution, name="accounts-attribution"), path("download-attribution/", accounts.download_attribution, name="accounts-download-attribution"), diff --git a/accounts/views.py b/accounts/views.py index 0da4eb205..be659f009 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -67,6 +67,7 @@ import utils.sound_upload from accounts.forms import ( + AIPreferenceForm, AvatarForm, BulkDescribeForm, DeleteUserForm, @@ -461,6 +462,49 @@ def edit_email_settings(request): return render(request, "accounts/edit_email_settings.html", tvars) +@login_required +@transaction.atomic() +def edit_ai_preferences(request): + profile = request.user.profile + + if request.method == "POST": + form = AIPreferenceForm(request.POST) + + if form.is_valid(): + # We get the existing preference so later we can compare if it has changed. + # Note that "default preference" is the same as "no preference", but we still want to + # save a "default preference" object so that we know if users have explicitly set it through the + # preferences panel. For this reason, here we don't want to get the default if the preference is not + # set, so that we can compare with the form value and properly create the object if needed. + old_ai_preference = str(profile.get_gen_ai_preference(default_if_not_set=False)) + old_ai_opt_out_speech = profile.get_gen_ai_preference_opt_out_speech() + + profile.set_gen_ai_preference( + form.cleaned_data["ai_sound_usage_preference"], form.cleaned_data["opt_out_speech"] + ) + + # If preference has changed, mark all sounds as index dirty + if ( + old_ai_preference != form.cleaned_data["ai_sound_usage_preference"] + or old_ai_opt_out_speech != form.cleaned_data["opt_out_speech"] + ): + Sound.objects.filter(user=request.user).update(is_index_dirty=True) + + msg_txt = "Your Gen AI preference options have been updated correctly." + messages.add_message(request, messages.INFO, msg_txt) + return HttpResponseRedirect(reverse("accounts-ai-preferences")) + else: + form = AIPreferenceForm( + initial={ + "ai_sound_usage_preference": profile.get_gen_ai_preference(), + "opt_out_speech": profile.get_gen_ai_preference_opt_out_speech(), + } + ) + + tvars = {"form": form, "activePage": "ai_preferences"} + return render(request, "accounts/edit_ai_preferences.html", tvars) + + @login_required @transaction.atomic() def edit(request): @@ -481,14 +525,9 @@ def is_selected(prefix): profile_form = ProfileForm(request, request.POST, instance=profile, prefix="profile") # Save a couple of variables that we will need later to check if they have changed and we need # to mark user sounds as index dirty or show some messages to the user - old_ai_preference = str(profile.get_ai_preference(default_if_not_set=False)) old_username = request.user.username old_sound_signature = profile.sound_signature if profile_form.is_valid(): - # Update AI sound usage preference, only if field present in the form - if "ai_sound_usage_preference" in profile_form.cleaned_data: - profile.set_ai_preference(profile_form.cleaned_data["ai_sound_usage_preference"]) - # Update username, this will create an entry in OldUsername request.user.username = profile_form.cleaned_data["username"] try: @@ -501,11 +540,8 @@ def is_selected(prefix): request.user.refresh_from_db(fields=["username"]) else: profile.save() - - # profile.refresh_from_db() # Refresh profile to get updated ai preference when calling get_ai_preference - if old_username != request.user.username or old_ai_preference != profile.get_ai_preference( - default_if_not_set=False - ): + # profile.refresh_from_db() # Refresh profile to get updated ai preference when calling get_gen_ai_preference + if old_username != request.user.username: Sound.objects.filter(user=request.user).update(is_index_dirty=True) invalidate_user_template_caches(request.user.id) @@ -659,7 +695,6 @@ def process_filter_and_sort_options(request, sort_options, tab): "sounds_processing_count": sounds_processing_count, "sounds_pending_description_count": sounds_pending_description_count, "packs_count": packs_count, - "user_has_ai_preference_unset": request.user.profile.get_ai_preference(default_if_not_set=False) is None, } # Then do dedicated processing for each tab diff --git a/apiv2/serializers.py b/apiv2/serializers.py index c06786ccf..50f655c00 100644 --- a/apiv2/serializers.py +++ b/apiv2/serializers.py @@ -42,7 +42,7 @@ + "geotag,is_geotagged,created,license,type,channels,filesize,bitrate," + "bitdepth,duration,samplerate,username,pack,pack_name,download,bookmark,previews,images," + "num_downloads,avg_rating,num_ratings,rate,comments,num_comments,comment,similar_sounds," - + "is_explicit,is_remix,was_remixed,md5,ai_preference" + + "is_explicit,is_remix,was_remixed,md5,gen_ai_preference" ) DEFAULT_FIELDS_IN_PACK_DETAIL = None # Separated by commas (None = all) @@ -145,7 +145,7 @@ class Meta: "is_geotagged", "created", "license", - "ai_preference", + "gen_ai_preference", "type", "channels", "filesize", @@ -222,10 +222,10 @@ def get_tags(self, obj): def get_license(self, obj): return obj.license.deed_url - ai_preference = serializers.SerializerMethodField() + gen_ai_preference = serializers.SerializerMethodField() - def get_ai_preference(self, obj): - return obj.user.profile.get_ai_preference() + def get_gen_ai_preference(self, obj): + return obj.user.profile.get_gen_ai_preference(category_code=obj.category_code) category = serializers.SerializerMethodField() @@ -445,7 +445,6 @@ class Meta: "packs", "num_posts", "num_comments", - "ai_preference", ) url = serializers.SerializerMethodField() @@ -522,11 +521,6 @@ def get_num_posts(self, obj): def get_num_comments(self, obj): return obj.comment_set.all().count() - ai_preference = serializers.SerializerMethodField() - - def get_ai_preference(self, obj): - return obj.profile.get_ai_preference() - ################## # PACK SERIALIZERS diff --git a/freesound/settings.py b/freesound/settings.py index d328437c9..8d06892fc 100644 --- a/freesound/settings.py +++ b/freesound/settings.py @@ -669,6 +669,16 @@ def load_broad_sound_taxonomy_from_csv(path): SEARCH_LOG_SLOW_QUERIES_MS_THRESHOLD = 1000 # Log search queries that take longer than this threshold in milliseconds. Set it to -1 to disable logging of slow queries. SEARCH_LOG_SLOW_QUERIES_QUERY_BASE_URL = "http://localhost:8983/solr/freesound/select/" +# ------------------------------------------------------------------------------- +# AI preferences panel +AI_PREF_NO_ADDITIONAL_PREFERENCES = "no-additional-preferences" +AI_PREF_OPEN_MODELS = "open-source-models" +AI_PREF_OPEN_NONCOMMERCIAL_MODELS = "noncommercial-open-source-models" +AI_PREF_NO_GEN_AI = "no-gen-ai" +DEFAULT_AI_PREFERENCE = AI_PREF_NO_ADDITIONAL_PREFERENCES +DEFAULT_OPT_OUT_SPEECH = False +AI_OPT_OUT_SPEECH_TAXONOMY_CODES = ["sp-s"] # Solo-speech + # ------------------------------------------------------------------------------- # Tag recommendation client settings TAGRECOMMENDATION_ADDRESS = "tagrecommendation" diff --git a/freesound/static/bw-frontend/src/components/toast.js b/freesound/static/bw-frontend/src/components/toast.js index 81002fd24..dc82725b0 100644 --- a/freesound/static/bw-frontend/src/components/toast.js +++ b/freesound/static/bw-frontend/src/components/toast.js @@ -1,4 +1,27 @@ let hideToastTimeout; +let hideToastAnimationTimeout; +const TOAST_ANIMATION_DURATION_MS = 260; + +const showToastElement = toastElement => { + clearTimeout(hideToastAnimationTimeout); + toastElement.classList.remove('toast--hiding'); + toastElement.style.display = 'block'; + + // Force reflow so the show animation restarts when toast is shown again. + void toastElement.offsetWidth; + toastElement.classList.add('toast--visible'); +}; + +const hideToastElement = toastElement => { + clearTimeout(hideToastAnimationTimeout); + toastElement.classList.remove('toast--visible'); + toastElement.classList.add('toast--hiding'); + + hideToastAnimationTimeout = setTimeout(() => { + toastElement.classList.remove('toast--hiding'); + toastElement.style.display = 'none'; + }, TOAST_ANIMATION_DURATION_MS); +}; const wrapTextInUl = text => { // We wrap toast messages in ul/li so that they are formatted the same when returned from Django and when directly triggered in javascript @@ -11,21 +34,21 @@ const wrapTextInUl = text => { export const showToast = (text, ulWrap) => { clearTimeout(hideToastTimeout); const toastElement = document.querySelector('[role="alert"]'); - toastElement.style.display = 'block'; + showToastElement(toastElement); if (ulWrap === true || ulWrap === undefined) { toastElement.children[0].innerHTML = wrapTextInUl(text); } else { toastElement.children[0].innerHTML = text; } hideToastTimeout = setTimeout(() => { - toastElement.style.display = 'none'; + hideToastElement(toastElement); }, 10000); }; export const showToastNoTimeout = (text, ulWrap) => { clearTimeout(hideToastTimeout); const toastElement = document.querySelector('[role="alert"]'); - toastElement.style.display = 'block'; + showToastElement(toastElement); if (ulWrap === true || ulWrap === undefined) { toastElement.children[0].innerHTML = wrapTextInUl(text); } else { @@ -36,5 +59,5 @@ export const showToastNoTimeout = (text, ulWrap) => { export const dismissToast = () => { clearTimeout(hideToastTimeout); const toastElement = document.querySelector('[role="alert"]'); - toastElement.style.display = 'none'; + hideToastElement(toastElement); }; diff --git a/freesound/static/bw-frontend/styles/molecules/toast.scss b/freesound/static/bw-frontend/styles/molecules/toast.scss index 572f8baf1..902d28ca3 100644 --- a/freesound/static/bw-frontend/styles/molecules/toast.scss +++ b/freesound/static/bw-frontend/styles/molecules/toast.scss @@ -3,6 +3,8 @@ z-index: 99999; display: none; position: fixed; + opacity: 0; + transform: translateY(20px); padding: 14px 14px; width: 100%; @include md-and-above { @@ -30,3 +32,45 @@ padding: 0; } } + +.toast.toast--visible { + animation: toast-slide-up 260ms ease-out forwards; +} + +.toast.toast--hiding { + animation: toast-slide-down 260ms ease-in forwards; +} + +@keyframes toast-slide-up { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes toast-slide-down { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(20px); + } +} + +@media (prefers-reduced-motion: reduce) { + .toast, + .toast.toast--visible, + .toast.toast--hiding { + animation: none; + opacity: 1; + transform: none; + } +} diff --git a/fscollections/migrations/0004_alter_collection_featured_sound_ids.py b/fscollections/migrations/0004_alter_collection_featured_sound_ids.py new file mode 100644 index 000000000..8f8721c16 --- /dev/null +++ b/fscollections/migrations/0004_alter_collection_featured_sound_ids.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2026-07-10 08:04 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fscollections', '0003_remove_collectionsound_featured_sound_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='collection', + name='featured_sound_ids', + field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, default=list, size=6), + ), + ] diff --git a/sounds/models.py b/sounds/models.py index b981ad84b..2d336c5d8 100644 --- a/sounds/models.py +++ b/sounds/models.py @@ -1770,6 +1770,9 @@ def get_second_level_category_search_url(self): else: return None + def get_gen_ai_preference(self): + return self.user.profile.get_gen_ai_preference(category_code=self.category_code) + def estimate_bpm_from_metadata(self, min_bpm=25, max_bpm=300): """ Estimate the bpm of a sound by looking at its description, tags and name. diff --git a/templates/accounts/account_settings_base.html b/templates/accounts/account_settings_base.html index 26b21293d..4398fcb8c 100644 --- a/templates/accounts/account_settings_base.html +++ b/templates/accounts/account_settings_base.html @@ -21,6 +21,9 @@

Account settings

+ diff --git a/templates/accounts/edit_ai_preferences.html b/templates/accounts/edit_ai_preferences.html new file mode 100644 index 000000000..15f526032 --- /dev/null +++ b/templates/accounts/edit_ai_preferences.html @@ -0,0 +1,66 @@ +{% extends "accounts/account_settings_base.html" %} + +{% load static %} +{% load filter_img %} +{% load bw_templatetags %} +{% load util %} + +{% block title %}Gen AI preferences{% endblock %} + +{% block main_content %} + +

Generative AI preferences

+ +
+
{% csrf_token %} +

+ This section allows you to set your preferences regarding the use of your uploaded sounds for the purpose of training generative Artificial Intelligence (Gen AI) models. +

+

+ The use of sounds for Gen AI training depends on your chosen Creative Commons license terms and how those terms are interpreted and applied. + You can add a statement to express your preferences for the specific case of training Gen AI models, however there is no guarantee that these will have any legal effect. + Please, select one of the options below: +

+
+ {% if form.ai_sound_usage_preference.errors %} +
{{ form.ai_sound_usage_preference.errors }}
+ {% endif %} +
+ {% for ai_option in form.ai_options_and_texts %} +
+ +
+ {% endfor %} +
+ +
+ Complementarily to the options above, you can make an extra statement to opt-out from Gen AI model training for the particular case of voice recordings. + This option is designed to express a preference against the use of such sounds for training models that may generate speech that could be misused to impersonate real people. + Mark the checkbox below to enable this option: +
+
+ {{ form.opt_out_speech.errors }} + + {{ form.opt_out_speech }} +
+
+
+ For more details about Generative AI preferences, including the way in which such preferences are treated, please read the + Usage of my sounds for training generative AI models help section. +
+ +
+ +
+ +{% endblock %} diff --git a/templates/accounts/manage_sounds.html b/templates/accounts/manage_sounds.html index ecb23daad..d85dbe533 100644 --- a/templates/accounts/manage_sounds.html +++ b/templates/accounts/manage_sounds.html @@ -129,13 +129,6 @@
Describing sounds is disabled... 😟
- {% if user_has_ai_preference_unset %} -
- {% bw_icon 'notification' %} You haven't set your preference regarding the usage of your sounds for training generative AI models. - While no preference is set, the default option will be used. - To disable this warning, please set your preference in your account settings page and save profile settings. -
- {% endif %} {% if tab == 'published' or tab == 'pending_moderation' %}
{% csrf_token %} diff --git a/utils/search/backends/solr555pysolr.py b/utils/search/backends/solr555pysolr.py index f2c263c91..6a04b9b55 100644 --- a/utils/search/backends/solr555pysolr.py +++ b/utils/search/backends/solr555pysolr.py @@ -330,9 +330,6 @@ def convert_sound_to_search_engine_document(self, sound): document["spectral_path_l"] = locations["display"]["spectral"]["L"]["path"] document["preview_path"] = locations["preview"]["LQ"]["mp3"]["path"] - # Index uploader AI preference - document["ai_preference"] = sound.user.profile.get_ai_preference() - # Index consolidated audio descriptors descriptors_to_index = {} descriptors_data = sound.get_consolidated_analysis_data() @@ -381,6 +378,9 @@ def convert_sound_to_search_engine_document(self, sound): f"{settings.SEARCH_SOUNDS_FIELD_SUBCATEGORY}{SOLR_DYNAMIC_FIELDS_SUFFIX_MAP[settings.AUDIO_DESCRIPTOR_TYPE_STRING]}" ] = user_provided_subcategory + # Index uploader AI preference + document["gen_ai_preference"] = sound.get_gen_ai_preference() + # Finally add the sound ID and content type document.update({"id": sound.id, "content_type": SOLR_DOC_CONTENT_TYPES["sound"]}) diff --git a/utils/search/schema/freesound.json b/utils/search/schema/freesound.json index c57f91978..6d298d9e1 100644 --- a/utils/search/schema/freesound.json +++ b/utils/search/schema/freesound.json @@ -246,7 +246,7 @@ "stored": false }, { - "name": "ai_preference", + "name": "gen_ai_preference", "type": "string", "indexed": true, "stored": false