SoundBoardData.cs

Data model and lookup utilities for a soundboard. Defines SoundPad and SoundBoardTab types, a catalog of tabs, methods to get pads by tab id, build unique/trending pools, and search/filter pads by text.

File Access
using System;
using System.Collections.Generic;
using System.Linq;

namespace SSoundboard;

public sealed class SoundPad
{
	public string Label { get; init; }
	public string SoundId { get; init; }
	public string Color { get; init; }
	public float Volume { get; init; } = 1f;
}

public sealed class SoundBoardTab
{
	public string Id { get; init; }
	public string Label { get; init; }
	public string Icon { get; init; }
	public List<SoundPad> Pads { get; init; } = new();
}

/// <summary>
/// Tab catalog + pad routing. Pad lists live in SoundBoardPads.generated.cs
/// (chunked builders so C# static-ctor IL limits never break publish).
/// Source of truth for rebuild: Assets/sounds/pads-*.json via publish/rebuild_tabs.py.
/// </summary>
public static partial class SoundBoardData
{
	// Keep in sync with publish/pad_tags.py CATEGORY_TABS (+ arnold/meme).
	static readonly IReadOnlyList<SoundBoardTab> TabCatalog = new List<SoundBoardTab>
	{
		new() { Id = "arnold", Label = "ARNOLD", Icon = "💪" },
		new() { Id = "gas", Label = "GAS", Icon = "💨" },
		new() { Id = "reaction", Label = "REACTIONS", Icon = "😂" },
		new() { Id = "game", Label = "VIDEO GAMES", Icon = "🎮" },
		new() { Id = "anime", Label = "ANIME", Icon = "🎌" },
		new() { Id = "movie", Label = "MOVIES", Icon = "🎬" },
		new() { Id = "cartoon", Label = "CARTOONS", Icon = "📺" },
		new() { Id = "tv", Label = "TV", Icon = "📡" },
		new() { Id = "horror", Label = "HORROR", Icon = "👻" },
		new() { Id = "sfx", Label = "SFX", Icon = "🔊" },
		new() { Id = "sports", Label = "SPORTS", Icon = "⚽" },
		new() { Id = "vehicles", Label = "VEHICLES", Icon = "🚗" },
		new() { Id = "weapons", Label = "WEAPONS", Icon = "🔫" },
		new() { Id = "politics", Label = "POLITICS", Icon = "🏛️" },
		new() { Id = "brainrot", Label = "BRAINROT", Icon = "🧠" },
		new() { Id = "uwu", Label = "UWU", Icon = "🥺" },
		new() { Id = "music", Label = "MUSIC", Icon = "🎵" },
		new() { Id = "app", Label = "APP", Icon = "📱" },
		new() { Id = "voice", Label = "VOICE", Icon = "🗣️" },
		new() { Id = "nature", Label = "NATURE", Icon = "🌿" },
		new() { Id = "animals", Label = "ANIMALS", Icon = "🐾" },
		new() { Id = "meme", Label = "MEME", Icon = "🤡" },
	};

	/// <summary>Warm set for first screen (not every Arnold pad).</summary>
	public static IEnumerable<string> StartupSoundIds =>
		ArnoldPads.Select( pad => pad.SoundId ).Take( 48 );

	public static IReadOnlyList<SoundBoardTab> Boards => TabCatalog;

	public static IReadOnlyList<SoundPad> GetPads( string boardId )
	{
		if ( string.IsNullOrWhiteSpace( boardId ) )
			return Array.Empty<SoundPad>();

		return boardId.ToLowerInvariant() switch
		{
			"new" => NewPads,
			"gas" => GasPads,
			"reaction" or "reactions" => ReactionPads,
			"game" or "games" => GamesPads,
			"anime" => AnimePads,
			"movie" or "movies" => MoviePads,
			"cartoon" or "cartoons" => CartoonPads,
			"tv" => TvPads,
			"horror" => HorrorPads,
			"sfx" or "sound-effect" => SfxPads,
			"sports" => SportsPads,
			"vehicles" or "vehicle" => VehiclePads,
			"weapons" or "weapon" => WeaponPads,
			"politics" => PoliticsPads,
			"brainrot" => BrainrotPads,
			"uwu" => UwuPads,
			"music" => MusicPads,
			"app" => AppPads,
			"voice" => VoicePads,
			"nature" => NaturePads,
			"animals" => AnimalsPads,
			"meme" => MemePads,
			"arnold" => ArnoldPads,
			"trending" => SoundPlayTracker.BuildTrendingPads( TrendingPool, TrendingFallbackPads ),
			_ => Array.Empty<SoundPad>()
		};
	}

	static IReadOnlyList<SoundPad> TrendingPool => _trendingPool ??= BuildTrendingPool();
	static IReadOnlyList<SoundPad> _trendingPool;

	/// <summary>Every unique pad across all boards (for global search).</summary>
	public static IReadOnlyList<SoundPad> AllUniquePads => _allUniquePads ??= BuildAllUniquePads();
	static IReadOnlyList<SoundPad> _allUniquePads;

	static IReadOnlyList<SoundPad> BuildTrendingPool()
	{
		var pads = new Dictionary<string, SoundPad>( StringComparer.OrdinalIgnoreCase );
		AddPads( pads, NewPads );
		AddPads( pads, MemePads );
		AddPads( pads, ArnoldPads );
		AddPads( pads, TrendingFallbackPads );
		return pads.Values.ToList();
	}

	static IReadOnlyList<SoundPad> BuildAllUniquePads()
	{
		var pads = new Dictionary<string, SoundPad>( StringComparer.OrdinalIgnoreCase );
		AddPads( pads, NewPads );
		AddPads( pads, MemePads );
		AddPads( pads, ArnoldPads );
		AddPads( pads, TrendingFallbackPads );
		foreach ( var tab in TabCatalog )
			AddPads( pads, GetPads( tab.Id ) );
		return pads.Values.ToList();
	}

	static void AddPads( Dictionary<string, SoundPad> pads, IEnumerable<SoundPad> source )
	{
		if ( source is null )
			return;
		foreach ( var pad in source )
		{
			if ( pad is null || string.IsNullOrWhiteSpace( pad.SoundId ) )
				continue;
			pads[pad.SoundId] = pad;
		}
	}

	/// <summary>
	/// Global search across every board. Comma-separated terms are OR
	/// (e.g. "dolphin, ocean" matches either word in label/id).
	/// </summary>
	public static IReadOnlyList<SoundPad> SearchAll( string query )
	{
		var terms = ParseSearchTerms( query );
		if ( terms.Count == 0 )
			return Array.Empty<SoundPad>();

		var all = AllUniquePads;
		var matches = new List<SoundPad>( 64 );
		for ( var i = 0; i < all.Count; i++ )
		{
			var pad = all[i];
			if ( PadMatchesAnyTerm( pad, terms ) )
				matches.Add( pad );
		}

		matches.Sort( static ( a, b ) =>
			string.Compare( a.Label, b.Label, StringComparison.OrdinalIgnoreCase ) );
		return matches;
	}

	public static List<string> ParseSearchTerms( string query )
	{
		var terms = new List<string>();
		if ( string.IsNullOrWhiteSpace( query ) )
			return terms;

		// Split on comma (and optional semicolon) — "dolphin, ocean" → OR search.
		var parts = query.Split( new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries );
		foreach ( var part in parts )
		{
			var t = part.Trim();
			if ( t.Length > 0 )
				terms.Add( t );
		}

		// No commas: one term (may contain spaces, e.g. "half life").
		if ( terms.Count == 0 )
		{
			var single = query.Trim();
			if ( single.Length > 0 )
				terms.Add( single );
		}

		return terms;
	}

	public static bool PadMatchesAnyTerm( SoundPad pad, List<string> terms )
	{
		if ( pad is null || terms is null || terms.Count == 0 )
			return false;

		for ( var i = 0; i < terms.Count; i++ )
		{
			if ( PadMatchesTerm( pad, terms[i] ) )
				return true;
		}

		return false;
	}

	public static bool PadMatchesTerm( SoundPad pad, string term )
	{
		if ( pad is null || string.IsNullOrWhiteSpace( term ) )
			return false;

		term = term.Trim();
		if ( !string.IsNullOrEmpty( pad.Label )
			&& pad.Label.Contains( term, StringComparison.OrdinalIgnoreCase ) )
			return true;

		if ( string.IsNullOrEmpty( pad.SoundId ) )
			return false;

		var slash = pad.SoundId.LastIndexOf( '/' );
		var stem = slash >= 0 ? pad.SoundId[(slash + 1)..] : pad.SoundId;
		if ( stem.Contains( term, StringComparison.OrdinalIgnoreCase ) )
			return true;

		var spaced = stem.Replace( '-', ' ' ).Replace( '_', ' ' );
		if ( spaced.Contains( term, StringComparison.OrdinalIgnoreCase ) )
			return true;

		var compactTerm = term.Replace( " ", "" ).Replace( "-", "" ).Replace( "_", "" );
		if ( compactTerm.Length >= 2 )
		{
			var compactStem = stem.Replace( "-", "" ).Replace( "_", "" );
			if ( compactStem.Contains( compactTerm, StringComparison.OrdinalIgnoreCase ) )
				return true;
		}

		return false;
	}

	// Used by SoundBoardPads.generated.cs builders.
	internal static SoundPad Pad( string soundId, string color, string label )
		=> Pad( soundId, color, 1f, label );

	internal static SoundPad Pad( string soundId, string color, float volume = 1f, string label = null )
	{
		return new SoundPad
		{
			Label = (label ?? soundId.Split( '/' ).Last().Replace( '-', ' ' ).Replace( '_', ' ' )).ToUpperInvariant(),
			SoundId = soundId,
			Color = color,
			Volume = volume
		};
	}
}