diff --git a/Sources/MusicAssistantApp/Presentation/LanguageBrowserView.swift b/Sources/MusicAssistantApp/Presentation/LanguageBrowserView.swift new file mode 100644 index 0000000..ce4b3cf --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/LanguageBrowserView.swift @@ -0,0 +1,128 @@ +import MusicAssistantCore +import SwiftUI + +struct LanguageBrowserView: View { + @Binding private var project: SongProject + private let saveProject: (SongProject) async -> Bool + private let catalog: LocalLanguageCatalog + + @State private var searchText = "" + @State private var selectedFamilyCategory: String? + @State private var hasUnsavedSelectionChanges = false + @State private var isSaving = false + @Environment(\.dismiss) private var dismiss + + init( + project: Binding, + saveProject: @escaping (SongProject) async -> Bool = { _ in true }, + catalog: LocalLanguageCatalog = LocalLanguageCatalog() + ) { + _project = project + self.saveProject = saveProject + self.catalog = catalog + } + + var body: some View { + NavigationStack { + List( + catalog.languages( + matching: searchText, + familyCategory: selectedFamilyCategory + ), + id: \.id + ) { language in + Toggle(isOn: selectionBinding(for: language)) { + VStack(alignment: .leading, spacing: 4) { + Text(language.name) + .font(.body.weight(.medium)) + + Text(language.familyCategory) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + .padding(.vertical, 2) + } + .navigationTitle("Language Catalog") + .searchable(text: $searchText, prompt: "Search languages") + .toolbar { + ToolbarItem(placement: .automatic) { + Picker("Region", selection: $selectedFamilyCategory) { + Text("All regions").tag(String?.none) + + ForEach(catalog.familyCategories, id: \.self) { familyCategory in + Text(familyCategory).tag(Optional(familyCategory)) + } + } + .pickerStyle(.menu) + } + + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + Task { + await saveSelectionChangesIfNeeded() + dismiss() + } + } + .disabled(isSaving) + } + + if isSaving { + ToolbarItem(placement: .status) { + ProgressView() + .controlSize(.small) + } + } + } + } + .frame(minWidth: 480, minHeight: 560) + .onDisappear { + guard hasUnsavedSelectionChanges, !isSaving else { return } + + let projectToSave = project + hasUnsavedSelectionChanges = false + + Task { + _ = await saveProject(projectToSave) + } + } + } + + private func selectionBinding(for item: LanguageCatalogItem) -> Binding { + Binding { + project.language(for: item.id) != nil + } set: { isSelected in + if isSelected { + project.upsertLanguage(item.language) + } else { + project.removeLanguage(id: item.id) + } + hasUnsavedSelectionChanges = true + } + } + + private func saveSelectionChangesIfNeeded() async { + guard hasUnsavedSelectionChanges else { return } + + isSaving = true + let didSave = await saveProject(project) + hasUnsavedSelectionChanges = !didSave + isSaving = false + } +} + +#Preview { + LanguageBrowserPreview() +} + +private struct LanguageBrowserPreview: View { + @State private var project = SongProject( + title: "Language Catalog Preview", + idea: "Choose song languages." + ) + + var body: some View { + LanguageBrowserView(project: $project) + } +} diff --git a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift index 7a6d317..be72d73 100644 --- a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift +++ b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift @@ -6,10 +6,10 @@ struct ProjectInspectorView: View { let saveProject: (SongProject) async -> Bool @State private var newInstrumentName = "" - @State private var newLanguageName = "" @State private var isInstrumentBrowserPresented = false @State private var isGenreStyleBrowserPresented = false @State private var isVocalistBrowserPresented = false + @State private var isLanguageBrowserPresented = false @State private var isSaving = false @State private var saveStatus: SaveStatus? @@ -100,17 +100,18 @@ struct ProjectInspectorView: View { } Section("Languages") { + Button { + isLanguageBrowserPresented = true + } label: { + Label("Browse languages", systemImage: "character.book.closed") + } + EditableTokenList( values: project.languages.map(\.name), removeAction: { index in project.removeLanguage(id: project.languages[index].id) } ) - AddTextRow( - placeholder: "Add language", - text: $newLanguageName, - addAction: addLanguage - ) } Section("Arabic Pronunciation") { @@ -157,6 +158,9 @@ struct ProjectInspectorView: View { .sheet(isPresented: $isVocalistBrowserPresented) { VocalistBrowserView(project: $project, saveProject: saveProject) } + .sheet(isPresented: $isLanguageBrowserPresented) { + LanguageBrowserView(project: $project, saveProject: saveProject) + } } private var durationSecondsBinding: Binding { @@ -187,13 +191,6 @@ struct ProjectInspectorView: View { newInstrumentName = "" } - private func addLanguage() { - let name = trimmed(newLanguageName) - guard !name.isEmpty else { return } - project.upsertLanguage(LanguageSelection(id: stableID(from: name), name: name)) - newLanguageName = "" - } - private func save() async { isSaving = true let didSave = await saveProject(project) diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index eab39dd..5e1fa35 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -535,6 +535,32 @@ public struct LanguageSelection: Codable, Equatable, Identifiable, Sendable { } } +public struct LanguageCatalogItem: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var name: String + public var familyCategory: String + public var aliases: [String] + public var searchTerms: [String] + + public init( + id: String, + name: String, + familyCategory: String, + aliases: [String] = [], + searchTerms: [String] = [] + ) { + self.id = id + self.name = name + self.familyCategory = familyCategory + self.aliases = aliases + self.searchTerms = searchTerms + } + + public var language: LanguageSelection { + LanguageSelection(id: id, name: name) + } +} + public struct DialectSelection: Codable, Equatable, Identifiable, Sendable { public let id: String public var name: String diff --git a/Sources/MusicAssistantCore/Services/LanguageCatalog/LocalLanguageCatalog.swift b/Sources/MusicAssistantCore/Services/LanguageCatalog/LocalLanguageCatalog.swift new file mode 100644 index 0000000..16b26e4 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/LanguageCatalog/LocalLanguageCatalog.swift @@ -0,0 +1,172 @@ +import Foundation + +public struct LocalLanguageCatalog: Sendable { + public let items: [LanguageCatalogItem] + + public init(items: [LanguageCatalogItem] = LocalLanguageCatalog.worldwideItems) { + self.items = items + } + + public var familyCategories: [String] { + Array(Set(items.map(\.familyCategory))) + .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + public func languages( + matching query: String, + familyCategory: String? = nil + ) -> [LanguageCatalogItem] { + let searchTokens = Self.searchTokens(for: query) + let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines) + + return items + .filter { item in + let matchesSearch = searchTokens.isEmpty || searchTokens.allSatisfy { token in + Self.searchableTerms(for: item).contains { $0.contains(token) } + } + let matchesFamily = selectedFamilyCategory?.isEmpty != false || item.familyCategory == selectedFamilyCategory + return matchesSearch && matchesFamily + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + public static let worldwideItems: [LanguageCatalogItem] = + middleEastAndNorthAfrica + europe + southAsia + eastAsia + southeastAsia + africa + americas + oceaniaAndPacific + + private static let middleEastAndNorthAfrica: [LanguageCatalogItem] = [ + item("arabic", "Arabic", "Middle East & North Africa", aliases: ["العربية"], terms: ["عربي"]), + item("hebrew", "Hebrew", "Middle East & North Africa", aliases: ["עברית"]), + item("persian", "Persian", "Middle East & North Africa", aliases: ["Farsi", "فارسی"]), + item("turkish", "Turkish", "Middle East & North Africa", aliases: ["Türkçe"]), + item("kurdish", "Kurdish", "Middle East & North Africa", aliases: ["Kurdî"]), + item("armenian", "Armenian", "Middle East & North Africa"), + item("azerbaijani", "Azerbaijani", "Middle East & North Africa", aliases: ["Azeri"]), + item("tamazight", "Tamazight", "Middle East & North Africa", aliases: ["Berber"]) + ] + + private static let europe: [LanguageCatalogItem] = [ + item("english", "English", "Europe", aliases: ["English language"]), + item("german", "German", "Europe", aliases: ["Deutsch"]), + item("french", "French", "Europe", aliases: ["Français"]), + item("spanish", "Spanish", "Europe", aliases: ["Español"]), + item("portuguese", "Portuguese", "Europe", aliases: ["Português"]), + item("italian", "Italian", "Europe", aliases: ["Italiano"]), + item("russian", "Russian", "Europe", aliases: ["Русский"]), + item("ukrainian", "Ukrainian", "Europe", aliases: ["Українська"]), + item("polish", "Polish", "Europe", aliases: ["Polski"]), + item("dutch", "Dutch", "Europe", aliases: ["Nederlands"]), + item("swedish", "Swedish", "Europe", aliases: ["Svenska"]), + item("norwegian", "Norwegian", "Europe", aliases: ["Norsk"]), + item("danish", "Danish", "Europe", aliases: ["Dansk"]), + item("finnish", "Finnish", "Europe", aliases: ["Suomi"]), + item("greek", "Greek", "Europe", aliases: ["Ελληνικά"]), + item("romanian", "Romanian", "Europe", aliases: ["Română"]), + item("czech", "Czech", "Europe", aliases: ["Čeština"]), + item("hungarian", "Hungarian", "Europe", aliases: ["Magyar"]), + item("serbian", "Serbian", "Europe"), + item("croatian", "Croatian", "Europe"), + item("bulgarian", "Bulgarian", "Europe") + ] + + private static let southAsia: [LanguageCatalogItem] = [ + item("hindi", "Hindi", "South Asia", aliases: ["हिन्दी"]), + item("urdu", "Urdu", "South Asia", aliases: ["اردو"]), + item("punjabi", "Punjabi", "South Asia", aliases: ["ਪੰਜਾਬੀ"]), + item("bengali", "Bengali", "South Asia", aliases: ["বাংলা"]), + item("tamil", "Tamil", "South Asia", aliases: ["தமிழ்"]), + item("telugu", "Telugu", "South Asia", aliases: ["తెలుగు"]), + item("marathi", "Marathi", "South Asia", aliases: ["मराठी"]), + item("gujarati", "Gujarati", "South Asia", aliases: ["ગુજરાતી"]), + item("malayalam", "Malayalam", "South Asia", aliases: ["മലയാളം"]), + item("kannada", "Kannada", "South Asia", aliases: ["ಕನ್ನಡ"]), + item("sinhala", "Sinhala", "South Asia"), + item("nepali", "Nepali", "South Asia", aliases: ["नेपाली"]), + item("pashto", "Pashto", "South Asia", aliases: ["پښتو"]) + ] + + private static let eastAsia: [LanguageCatalogItem] = [ + item("mandarin-chinese", "Mandarin Chinese", "East Asia", aliases: ["Mandarin", "中文", "普通话"]), + item("cantonese", "Cantonese", "East Asia", aliases: ["廣東話"]), + item("japanese", "Japanese", "East Asia", aliases: ["日本語"]), + item("korean", "Korean", "East Asia", aliases: ["한국어"]), + item("mongolian", "Mongolian", "East Asia") + ] + + private static let southeastAsia: [LanguageCatalogItem] = [ + item("indonesian", "Indonesian", "Southeast Asia", aliases: ["Bahasa Indonesia"]), + item("malay", "Malay", "Southeast Asia", aliases: ["Bahasa Melayu"]), + item("vietnamese", "Vietnamese", "Southeast Asia", aliases: ["Tiếng Việt"]), + item("thai", "Thai", "Southeast Asia", aliases: ["ไทย"]), + item("tagalog", "Tagalog", "Southeast Asia", aliases: ["Filipino"]), + item("burmese", "Burmese", "Southeast Asia", aliases: ["Myanmar"]), + item("khmer", "Khmer", "Southeast Asia"), + item("lao", "Lao", "Southeast Asia"), + item("javanese", "Javanese", "Southeast Asia") + ] + + private static let africa: [LanguageCatalogItem] = [ + item("swahili", "Swahili", "Africa", aliases: ["Kiswahili"]), + item("amharic", "Amharic", "Africa", aliases: ["አማርኛ"]), + item("hausa", "Hausa", "Africa"), + item("yoruba", "Yoruba", "Africa"), + item("igbo", "Igbo", "Africa"), + item("zulu", "Zulu", "Africa"), + item("xhosa", "Xhosa", "Africa"), + item("somali", "Somali", "Africa"), + item("oromo", "Oromo", "Africa"), + item("afrikaans", "Afrikaans", "Africa"), + item("wolof", "Wolof", "Africa"), + item("shona", "Shona", "Africa") + ] + + private static let americas: [LanguageCatalogItem] = [ + item("haitian-creole", "Haitian Creole", "Americas", aliases: ["Kreyòl"]), + item("quechua", "Quechua", "Americas"), + item("guarani", "Guarani", "Americas"), + item("navajo", "Navajo", "Americas"), + item("inuktitut", "Inuktitut", "Americas") + ] + + private static let oceaniaAndPacific: [LanguageCatalogItem] = [ + item("maori", "Maori", "Oceania & Pacific", aliases: ["Te Reo Māori"]), + item("hawaiian", "Hawaiian", "Oceania & Pacific"), + item("samoan", "Samoan", "Oceania & Pacific"), + item("tongan", "Tongan", "Oceania & Pacific"), + item("fijian", "Fijian", "Oceania & Pacific") + ] + + private static func item( + _ id: String, + _ name: String, + _ familyCategory: String, + aliases: [String] = [], + terms: [String] = [] + ) -> LanguageCatalogItem { + LanguageCatalogItem( + id: id, + name: name, + familyCategory: familyCategory, + aliases: aliases, + searchTerms: terms + ) + } + + private static func searchableTerms(for item: LanguageCatalogItem) -> [String] { + searchTokens(for: [item.name, item.familyCategory] + item.aliases + item.searchTerms) + } + + private static func searchTokens(for query: String) -> [String] { + searchTokens(for: [query]) + } + + private static func searchTokens(for values: [String]) -> [String] { + values + .joined(separator: " ") + .folding( + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], + locale: Locale(identifier: "en_US_POSIX") + ) + .split(whereSeparator: { $0.isWhitespace || $0 == "-" }) + .map(String.init) + } +} diff --git a/Tests/MusicAssistantCoreTests/LocalLanguageCatalogTests.swift b/Tests/MusicAssistantCoreTests/LocalLanguageCatalogTests.swift new file mode 100644 index 0000000..d95076e --- /dev/null +++ b/Tests/MusicAssistantCoreTests/LocalLanguageCatalogTests.swift @@ -0,0 +1,61 @@ +import MusicAssistantCore +import XCTest + +final class LocalLanguageCatalogTests: XCTestCase { + func testWorldwideCatalogSearchesArabicTermsAndRegionalCategories() { + let catalog = LocalLanguageCatalog() + let expectedCategories: Set = [ + "Middle East & North Africa", + "Europe", + "South Asia", + "East Asia", + "Southeast Asia", + "Africa", + "Americas", + "Oceania & Pacific" + ] + + XCTAssertGreaterThan(catalog.items.count, 70) + XCTAssertTrue(expectedCategories.isSubset(of: Set(catalog.familyCategories))) + XCTAssertEqual(catalog.languages(matching: "عربي").map(\.id), ["arabic"]) + XCTAssertEqual(catalog.languages(matching: "한국어").map(\.id), ["korean"]) + XCTAssertEqual( + catalog.languages(matching: "", familyCategory: "South Asia").map(\.id), + [ + "bengali", + "gujarati", + "hindi", + "kannada", + "malayalam", + "marathi", + "nepali", + "pashto", + "punjabi", + "sinhala", + "tamil", + "telugu", + "urdu" + ] + ) + } + + func testDeselectingLanguageRemovesItsLinkedDialects() { + var project = SongProject( + title: "Language Project", + idea: "Use Arabic and English", + languages: [ + LanguageSelection(id: "arabic", name: "Arabic"), + LanguageSelection(id: "english", name: "English") + ], + dialects: [ + DialectSelection(id: "levantine", name: "Levantine", languageID: "arabic"), + DialectSelection(id: "us-english", name: "US English", languageID: "english") + ] + ) + + project.removeLanguage(id: "arabic") + + XCTAssertEqual(project.languages.map(\.id), ["english"]) + XCTAssertEqual(project.dialects.map(\.id), ["us-english"]) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b567737..904ac0b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,7 @@ macOS App │ ├── Genre & Style Catalog │ ├── Instrument Catalog │ ├── Vocalist Catalog +│ ├── Language Catalog │ ├── Arabic Pronunciation Processor │ ├── Project Versioning │ └── Validation @@ -126,3 +127,10 @@ Vocalist presets are local structured data, separate from SwiftUI views. The catalog service supplies stable ids and preset metadata, while the Presentation layer searches and toggles the corresponding `Vocalist` values on the SongProject. + +## Language Selector Boundary + +Language catalog data is local structured data, separate from SwiftUI +views. The catalog service owns stable ids, regional categories and +search terms; the Presentation layer toggles the corresponding +`LanguageSelection` values on the SongProject. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 40fd96f..db49c24 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -60,6 +60,13 @@ VocalistPreset - aliases[] - searchTerms[] +LanguageCatalogItem +- id +- name +- familyCategory +- aliases[] +- searchTerms[] + SongSection - id - type (intro, verse, preChorus, chorus, bridge, outro, custom) diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md index 9329038..620ed42 100644 --- a/docs/OPEN_QUESTIONS.md +++ b/docs/OPEN_QUESTIONS.md @@ -12,7 +12,9 @@ finalized. - Complete worldwide instrument catalog taxonomy, family/category groups, region/origin coverage, aliases/search terms and maintenance strategy. -- Exact list of supported languages/dialects for MVP. +- Resolved: languages use a large curated local catalog with category + browsing, search aliases and checkbox multi-select. Exact dialect + preset coverage remains to be defined. - Arabic diacritization policy: full tashkeel vs pronunciation-targeted tashkeel. - External music-generation handoff is not in the current scope. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index f76931e..9894efa 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -41,6 +41,8 @@ and writing engine, then prepares final lyrics and style instructions. - User controls vocal gender/type, rap/singing mode, delivery and section-specific performance. - Multiple languages and dialects can be used in one song. +- Languages can be selected from a large local catalog with search, + category browsing and checkbox multi-select. - Arabic receives dedicated diacritics/harakat/tanween handling for pronunciation. - Energy and emotional progression can change throughout the song. @@ -94,6 +96,13 @@ presets. Users can search, browse by category and select or deselect multiple vocalists with checkboxes. Each preset supplies a role label, voice type, gender selection and performance style for the Song Project. +## Language Selector + +The application provides a large curated local language catalog. Users +can search, browse by regional category and select or deselect multiple +languages with checkboxes. Removing a language also removes any dialect +entries attached to it from the Song Project. + ## Project Management Every song is stored as a project. Important changes can create versions diff --git a/docs/TASKS.md b/docs/TASKS.md index ed81848..5b58749 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -133,3 +133,4 @@ lyrics and style prompt in the project. - [x] Add confirmed project deletion from the project browser. - [x] Add a worldwide genre and style selector with checkbox multi-select. - [x] Add a vocalist preset selector with checkbox multi-select. +- [x] Add a language selector with checkbox multi-select.