Add language catalog selector
This commit is contained in:
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user