From 48b694bbd875740bbdb350001bb688c2fbf4ad1f Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 4 Aug 2025 01:30:33 +0200 Subject: feat: sound effects functionality with freesound's api --- apps/web/src/app/api/sounds/search/route.ts | 254 ++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 apps/web/src/app/api/sounds/search/route.ts (limited to 'apps/web/src/app/api/sounds') diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts new file mode 100644 index 00000000..15954140 --- /dev/null +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -0,0 +1,254 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/env"; +import { baseRateLimit } from "@/lib/rate-limit"; + +const searchParamsSchema = z.object({ + q: z.string().max(500, "Query too long").optional(), + type: z.enum(["songs", "effects"]).optional(), + page: z.coerce.number().int().min(1).max(1000).default(1), + page_size: z.coerce.number().int().min(1).max(150).default(20), + sort: z + .enum(["downloads", "rating", "created", "score"]) + .default("downloads"), + min_rating: z.coerce.number().min(0).max(5).default(3), +}); + +const freesoundResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string().url(), + previews: z + .object({ + "preview-hq-mp3": z.string().url(), + "preview-lq-mp3": z.string().url(), + "preview-hq-ogg": z.string().url(), + "preview-lq-ogg": z.string().url(), + }) + .optional(), + download: z.string().url().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + num_downloads: z.number().optional(), + avg_rating: z.number().optional(), + num_ratings: z.number().optional(), +}); + +const freesoundResponseSchema = z.object({ + count: z.number(), + next: z.string().url().nullable(), + previous: z.string().url().nullable(), + results: z.array(freesoundResultSchema), +}); + +const transformedResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string(), + previewUrl: z.string().optional(), + downloadUrl: z.string().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + downloads: z.number().optional(), + rating: z.number().optional(), + ratingCount: z.number().optional(), +}); + +const apiResponseSchema = z.object({ + count: z.number(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(transformedResultSchema), + query: z.string().optional(), + type: z.string(), + page: z.number(), + pageSize: z.number(), + sort: z.string(), + minRating: z.number().optional(), +}); + +export async function GET(request: NextRequest) { + try { + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + const { searchParams } = new URL(request.url); + + const validationResult = searchParamsSchema.safeParse({ + q: searchParams.get("q") || undefined, + type: searchParams.get("type") || undefined, + page: searchParams.get("page") || undefined, + page_size: searchParams.get("page_size") || undefined, + sort: searchParams.get("sort") || undefined, + min_rating: searchParams.get("min_rating") || undefined, + }); + + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { + q: query, + type, + page, + page_size: pageSize, + sort, + min_rating, + } = validationResult.data; + + if (type === "songs") { + return NextResponse.json( + { + error: "Songs are not available yet", + message: + "Song search functionality is coming soon. Try searching for sound effects instead.", + }, + { status: 501 } + ); + } + + const baseUrl = "https://freesound.org/apiv2/search/text/"; + + // Use score sorting for search queries, downloads for top sounds + const sortParam = query + ? sort === "score" + ? "score" + : `${sort}_desc` + : `${sort}_desc`; + + const params = new URLSearchParams({ + query: query || "", + token: env.FREESOUND_API_KEY, + page: page.toString(), + page_size: pageSize.toString(), + sort: sortParam, + fields: + "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", + }); + + // Always apply sound effect filters (since we're primarily a sound effects search) + if (type === "effects" || !type) { + params.append("filter", "duration:[* TO 30.0]"); + params.append("filter", `avg_rating:[${min_rating} TO *]`); + params.append( + "filter", + "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion" + ); + } + + const response = await fetch(`${baseUrl}?${params.toString()}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Freesound API error:", response.status, errorText); + return NextResponse.json( + { error: "Failed to search sounds" }, + { status: response.status } + ); + } + + const rawData = await response.json(); + + const freesoundValidation = freesoundResponseSchema.safeParse(rawData); + if (!freesoundValidation.success) { + console.error( + "Invalid Freesound API response:", + freesoundValidation.error + ); + return NextResponse.json( + { error: "Invalid response from Freesound API" }, + { status: 502 } + ); + } + + const data = freesoundValidation.data; + + const transformedResults = data.results.map((result) => ({ + id: result.id, + name: result.name, + description: result.description, + url: result.url, + previewUrl: + result.previews?.["preview-hq-mp3"] || + result.previews?.["preview-lq-mp3"], + downloadUrl: result.download, + duration: result.duration, + filesize: result.filesize, + type: result.type, + channels: result.channels, + bitrate: result.bitrate, + bitdepth: result.bitdepth, + samplerate: result.samplerate, + username: result.username, + tags: result.tags, + license: result.license, + created: result.created, + downloads: result.num_downloads || 0, + rating: result.avg_rating || 0, + ratingCount: result.num_ratings || 0, + })); + + const responseData = { + count: data.count, + next: data.next, + previous: data.previous, + results: transformedResults, + query: query || "", + type: type || "effects", + page, + pageSize, + sort, + minRating: min_rating, + }; + + const responseValidation = apiResponseSchema.safeParse(responseData); + if (!responseValidation.success) { + console.error( + "Invalid API response structure:", + responseValidation.error + ); + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + + return NextResponse.json(responseValidation.data); + } catch (error) { + console.error("Error searching sounds:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} -- cgit v1.3.1 From acc2089d0d6975d9cede833b083f9d1d2f78504d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 4 Aug 2025 02:59:07 +0200 Subject: feat: add license filtering for sound effects --- apps/web/src/app/api/sounds/search/route.ts | 11 ++++ .../components/editor/media-panel/views/sounds.tsx | 62 ++++++++++++++++++---- apps/web/src/components/ui/dropdown-menu.tsx | 10 ++-- apps/web/src/components/ui/input.tsx | 4 +- apps/web/src/hooks/use-sound-search.ts | 3 +- apps/web/src/stores/sounds-store.ts | 9 ++++ 6 files changed, 84 insertions(+), 15 deletions(-) (limited to 'apps/web/src/app/api/sounds') diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts index 15954140..c89bc76c 100644 --- a/apps/web/src/app/api/sounds/search/route.ts +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -12,6 +12,7 @@ const searchParamsSchema = z.object({ .enum(["downloads", "rating", "created", "score"]) .default("downloads"), min_rating: z.coerce.number().min(0).max(5).default(3), + commercial_only: z.coerce.boolean().default(true), }); const freesoundResultSchema = z.object({ @@ -124,6 +125,7 @@ export async function GET(request: NextRequest) { page_size: pageSize, sort, min_rating, + commercial_only, } = validationResult.data; if (type === "songs") { @@ -160,6 +162,15 @@ export async function GET(request: NextRequest) { if (type === "effects" || !type) { params.append("filter", "duration:[* TO 30.0]"); params.append("filter", `avg_rating:[${min_rating} TO *]`); + + // Filter by license if commercial_only is true + if (commercial_only) { + params.append( + "filter", + 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")' + ); + } + params.append( "filter", "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion" diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx index 5ffa51a9..ed0db8ab 100644 --- a/apps/web/src/components/editor/media-panel/views/sounds.tsx +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -5,11 +5,23 @@ import { useState, useMemo, useRef, useEffect } from "react"; import { Separator } from "@/components/ui/separator"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { PlayIcon, PauseIcon, HeartIcon, PlusIcon } from "lucide-react"; +import { + PlayIcon, + PauseIcon, + HeartIcon, + PlusIcon, + ListFilter, +} from "lucide-react"; import { useSoundsStore } from "@/stores/sounds-store"; import { useSoundSearch } from "@/hooks/use-sound-search"; import type { SoundEffect, SavedSound } from "@/types/sounds"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuCheckboxItem, +} from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, @@ -19,6 +31,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; export function SoundsView() { return ( @@ -66,6 +79,8 @@ function SoundEffectsView() { loadSavedSounds, isSoundSaved, toggleSavedSound, + showCommercialOnly, + toggleCommercialFilter, } = useSoundsStore(); const { results: searchResults, @@ -73,7 +88,7 @@ function SoundEffectsView() { loadMore, hasNextPage, isLoadingMore, - } = useSoundSearch(searchQuery); + } = useSoundSearch(searchQuery, showCommercialOnly); // Audio playback state const [playingId, setPlayingId] = useState(null); @@ -144,14 +159,41 @@ function SoundEffectsView() { return (
- setSearchQuery(e.target.value)} - showClearIcon - onClear={() => setSearchQuery("")} - /> +
+ setSearchQuery(e.target.value)} + showClearIcon + onClear={() => setSearchQuery("")} + /> + + + + + + + Show only commercially licensed + +
+ {showCommercialOnly + ? "Only showing sounds licensed for commercial use" + : "Showing all sounds regardless of license"} +
+
+
+
{ + e.preventDefault(); + }} {...props} > - + {children} + - {children} )); + DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 4fb0f5b4..440191f2 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -9,6 +9,7 @@ interface InputProps extends React.ComponentProps<"input"> { onShowPasswordChange?: (show: boolean) => void; showClearIcon?: boolean; onClear?: () => void; + containerClassName?: string; } const Input = React.forwardRef( @@ -16,6 +17,7 @@ const Input = React.forwardRef( { className, type, + containerClassName, showPassword, onShowPasswordChange, showClearIcon, @@ -45,7 +47,7 @@ const Input = React.forwardRef( iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : ""; return ( -
+
void; + // Search state searchQuery: string; searchResults: SoundEffect[]; @@ -72,6 +76,11 @@ export const useSoundsStore = create((set, get) => ({ isLoading: false, error: null, hasLoaded: false, + showCommercialOnly: true, + + toggleCommercialFilter: () => { + set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); + }, // Search state searchQuery: "", -- cgit v1.3.1