Add language catalog selector

This commit is contained in:
diyaa
2026-09-15 23:20:48 +02:00
parent b43c5416f2
commit c8242a844a
10 changed files with 425 additions and 14 deletions
@@ -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<SongProject>,
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<Bool> {
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)
}
}
@@ -6,10 +6,10 @@ struct ProjectInspectorView: View {
let saveProject: (SongProject) async -> Bool let saveProject: (SongProject) async -> Bool
@State private var newInstrumentName = "" @State private var newInstrumentName = ""
@State private var newLanguageName = ""
@State private var isInstrumentBrowserPresented = false @State private var isInstrumentBrowserPresented = false
@State private var isGenreStyleBrowserPresented = false @State private var isGenreStyleBrowserPresented = false
@State private var isVocalistBrowserPresented = false @State private var isVocalistBrowserPresented = false
@State private var isLanguageBrowserPresented = false
@State private var isSaving = false @State private var isSaving = false
@State private var saveStatus: SaveStatus? @State private var saveStatus: SaveStatus?
@@ -100,17 +100,18 @@ struct ProjectInspectorView: View {
} }
Section("Languages") { Section("Languages") {
Button {
isLanguageBrowserPresented = true
} label: {
Label("Browse languages", systemImage: "character.book.closed")
}
EditableTokenList( EditableTokenList(
values: project.languages.map(\.name), values: project.languages.map(\.name),
removeAction: { index in removeAction: { index in
project.removeLanguage(id: project.languages[index].id) project.removeLanguage(id: project.languages[index].id)
} }
) )
AddTextRow(
placeholder: "Add language",
text: $newLanguageName,
addAction: addLanguage
)
} }
Section("Arabic Pronunciation") { Section("Arabic Pronunciation") {
@@ -157,6 +158,9 @@ struct ProjectInspectorView: View {
.sheet(isPresented: $isVocalistBrowserPresented) { .sheet(isPresented: $isVocalistBrowserPresented) {
VocalistBrowserView(project: $project, saveProject: saveProject) VocalistBrowserView(project: $project, saveProject: saveProject)
} }
.sheet(isPresented: $isLanguageBrowserPresented) {
LanguageBrowserView(project: $project, saveProject: saveProject)
}
} }
private var durationSecondsBinding: Binding<String> { private var durationSecondsBinding: Binding<String> {
@@ -187,13 +191,6 @@ struct ProjectInspectorView: View {
newInstrumentName = "" 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 { private func save() async {
isSaving = true isSaving = true
let didSave = await saveProject(project) let didSave = await saveProject(project)
@@ -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 struct DialectSelection: Codable, Equatable, Identifiable, Sendable {
public let id: String public let id: String
public var name: String public var name: String
@@ -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)
}
}
@@ -0,0 +1,61 @@
import MusicAssistantCore
import XCTest
final class LocalLanguageCatalogTests: XCTestCase {
func testWorldwideCatalogSearchesArabicTermsAndRegionalCategories() {
let catalog = LocalLanguageCatalog()
let expectedCategories: Set<String> = [
"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"])
}
}
+8
View File
@@ -27,6 +27,7 @@ macOS App
│ ├── Genre & Style Catalog │ ├── Genre & Style Catalog
│ ├── Instrument Catalog │ ├── Instrument Catalog
│ ├── Vocalist Catalog │ ├── Vocalist Catalog
│ ├── Language Catalog
│ ├── Arabic Pronunciation Processor │ ├── Arabic Pronunciation Processor
│ ├── Project Versioning │ ├── Project Versioning
│ └── Validation │ └── 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 The catalog service supplies stable ids and preset metadata, while the
Presentation layer searches and toggles the corresponding `Vocalist` Presentation layer searches and toggles the corresponding `Vocalist`
values on the SongProject. 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.
+7
View File
@@ -60,6 +60,13 @@ VocalistPreset
- aliases[] - aliases[]
- searchTerms[] - searchTerms[]
LanguageCatalogItem
- id
- name
- familyCategory
- aliases[]
- searchTerms[]
SongSection SongSection
- id - id
- type (intro, verse, preChorus, chorus, bridge, outro, custom) - type (intro, verse, preChorus, chorus, bridge, outro, custom)
+3 -1
View File
@@ -12,7 +12,9 @@ finalized.
- Complete worldwide instrument catalog taxonomy, family/category - Complete worldwide instrument catalog taxonomy, family/category
groups, region/origin coverage, aliases/search terms and maintenance groups, region/origin coverage, aliases/search terms and maintenance
strategy. 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 - Arabic diacritization policy: full tashkeel vs
pronunciation-targeted tashkeel. pronunciation-targeted tashkeel.
- External music-generation handoff is not in the current scope. - External music-generation handoff is not in the current scope.
+9
View File
@@ -41,6 +41,8 @@ and writing engine, then prepares final lyrics and style instructions.
- User controls vocal gender/type, rap/singing mode, delivery and - User controls vocal gender/type, rap/singing mode, delivery and
section-specific performance. section-specific performance.
- Multiple languages and dialects can be used in one song. - 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 - Arabic receives dedicated diacritics/harakat/tanween handling for
pronunciation. pronunciation.
- Energy and emotional progression can change throughout the song. - 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, multiple vocalists with checkboxes. Each preset supplies a role label,
voice type, gender selection and performance style for the Song Project. 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 ## Project Management
Every song is stored as a project. Important changes can create versions Every song is stored as a project. Important changes can create versions
+1
View File
@@ -133,3 +133,4 @@ lyrics and style prompt in the project.
- [x] Add confirmed project deletion from the project browser. - [x] Add confirmed project deletion from the project browser.
- [x] Add a worldwide genre and style selector with checkbox multi-select. - [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 vocalist preset selector with checkbox multi-select.
- [x] Add a language selector with checkbox multi-select.