From f5ab97e6a089f82deaeeabe5ae94df9e2c993194 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 13 Sep 2026 21:44:42 +0200 Subject: [PATCH] Add Arabic pronunciation processing --- .../ArabicPronunciationProcessor.swift | 104 ++++++++++++++++++ .../ArabicPronunciationProcessorTests.swift | 67 +++++++++++ docs/ARCHITECTURE.md | 10 ++ docs/TASKS.md | 2 +- 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 Sources/MusicAssistantCore/Services/ArabicPronunciation/ArabicPronunciationProcessor.swift create mode 100644 Tests/MusicAssistantCoreTests/ArabicPronunciationProcessorTests.swift diff --git a/Sources/MusicAssistantCore/Services/ArabicPronunciation/ArabicPronunciationProcessor.swift b/Sources/MusicAssistantCore/Services/ArabicPronunciation/ArabicPronunciationProcessor.swift new file mode 100644 index 0000000..984a4f8 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/ArabicPronunciation/ArabicPronunciationProcessor.swift @@ -0,0 +1,104 @@ +import Foundation + +public struct ArabicPronunciationProcessor: Sendable { + public init() {} + + public func process( + lyrics: String, + settings: ArabicPronunciationSettings + ) -> ArabicPronunciationProcessingResult { + guard settings.isEnabled else { + return ArabicPronunciationProcessingResult( + text: lyrics, + notes: [.processingDisabled] + ) + } + + var processedText = lyrics.precomposedStringWithCanonicalMapping + var notes = processingNotes(for: processedText, settings: settings) + + if settings.tanweenPolicy == .removeWhenUnwanted { + processedText = removeTanween(from: processedText) + } + + if settings.tanweenPolicy == .addWhenPronunciationRequires, + containsArabicLetter(in: processedText), + !containsTanween(in: processedText) { + notes.append(.tanweenAdditionNeedsReview) + } + + return ArabicPronunciationProcessingResult( + text: processedText, + notes: notes + ) + } + + private func processingNotes( + for text: String, + settings: ArabicPronunciationSettings + ) -> [ArabicPronunciationProcessingNote] { + guard containsArabicLetter(in: text) else { + return [] + } + + switch settings.diacritizationPolicy { + case .unspecified: + return [.diacritizationPolicyUnspecified] + case .pronunciationTargeted where !containsDiacritics(in: text): + return [.pronunciationTargetedDiacriticsNeedReview] + case .fullTashkeel where !containsDiacritics(in: text): + return [.fullTashkeelNeedsReview] + default: + return [] + } + } + + private func removeTanween(from text: String) -> String { + String(text.unicodeScalars.filter { scalar in + !isTanween(scalar) + }) + } + + private func containsArabicLetter(in text: String) -> Bool { + text.unicodeScalars.contains { scalar in + let isArabicBlock = (0x0600...0x06FF).contains(scalar.value) + || (0x0750...0x077F).contains(scalar.value) + || (0x08A0...0x08FF).contains(scalar.value) + return isArabicBlock && CharacterSet.letters.contains(scalar) + } + } + + private func containsDiacritics(in text: String) -> Bool { + text.unicodeScalars.contains(where: isArabicDiacritic) + } + + private func containsTanween(in text: String) -> Bool { + text.unicodeScalars.contains(where: isTanween) + } + + private func isArabicDiacritic(_ scalar: Unicode.Scalar) -> Bool { + (0x064B...0x065F).contains(scalar.value) || scalar.value == 0x0670 + } + + private func isTanween(_ scalar: Unicode.Scalar) -> Bool { + (0x064B...0x064D).contains(scalar.value) + } +} + +public struct ArabicPronunciationProcessingResult: Equatable, Sendable { + public var text: String + public var notes: [ArabicPronunciationProcessingNote] + + public init(text: String, notes: [ArabicPronunciationProcessingNote] = []) { + self.text = text + self.notes = notes + } +} + +public enum ArabicPronunciationProcessingNote: Equatable, Sendable { + case processingDisabled + case diacritizationPolicyUnspecified + case pronunciationTargetedDiacriticsNeedReview + case fullTashkeelNeedsReview + case tanweenAdditionNeedsReview +} diff --git a/Tests/MusicAssistantCoreTests/ArabicPronunciationProcessorTests.swift b/Tests/MusicAssistantCoreTests/ArabicPronunciationProcessorTests.swift new file mode 100644 index 0000000..813db78 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/ArabicPronunciationProcessorTests.swift @@ -0,0 +1,67 @@ +import MusicAssistantCore +import XCTest + +final class ArabicPronunciationProcessorTests: XCTestCase { + private let processor = ArabicPronunciationProcessor() + + func testDisabledProcessingLeavesLyricsUntouched() { + let lyrics = "\u{0645}\u{0651}\u{064E}\u{0631}\u{062D}\u{064E}\u{0628}\u{064B}\u{0627}" + + let result = processor.process( + lyrics: lyrics, + settings: ArabicPronunciationSettings(isEnabled: false) + ) + + XCTAssertEqual(result.text, lyrics) + XCTAssertEqual(result.notes, [.processingDisabled]) + } + + func testProcessingNormalizesSuppliedDiacritics() { + let lyrics = "\u{0645}\u{0651}\u{064E}\u{0631}\u{062D}\u{064E}\u{0628}\u{064B}\u{0627}" + + let result = processor.process( + lyrics: lyrics, + settings: ArabicPronunciationSettings( + isEnabled: true, + diacritizationPolicy: .pronunciationTargeted + ) + ) + + XCTAssertEqual( + result.text, + "\u{0645}\u{064E}\u{0651}\u{0631}\u{062D}\u{064E}\u{0628}\u{064B}\u{0627}" + ) + XCTAssertTrue(result.notes.isEmpty) + } + + func testRemovingTanweenKeepsOtherDiacritics() { + let result = processor.process( + lyrics: "\u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{064B}\u{0627}", + settings: ArabicPronunciationSettings( + isEnabled: true, + tanweenPolicy: .removeWhenUnwanted + ) + ) + + XCTAssertEqual(result.text, "\u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{0627}") + } + + func testFullTashkeelDoesNotInventMissingMarks() { + let lyrics = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}" + + let result = processor.process( + lyrics: lyrics, + settings: ArabicPronunciationSettings( + isEnabled: true, + diacritizationPolicy: .fullTashkeel, + tanweenPolicy: .addWhenPronunciationRequires + ) + ) + + XCTAssertEqual(result.text, lyrics) + XCTAssertEqual( + result.notes, + [.fullTashkeelNeedsReview, .tanweenAdditionNeedsReview] + ) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d364f4a..e1293de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -58,6 +58,16 @@ retry delay when available, and propagates cancellation without retrying. A deterministic layer converts the approved SongProject into the final Suno-facing lyrics/style content. User choices override AI suggestions. +## Arabic Pronunciation Processor + +The Arabic Pronunciation Processor is a local Application Service. It +normalizes user-supplied Arabic diacritics and applies the selected +tanween policy without choosing a diacritization policy for the user. +It must not invent missing vowel marks or tanween when no reliable +linguistic source is available; instead, it returns a review note so a +later user-review flow can present the unresolved text. This processor +does not use external services or APIs. + ## Security - Never commit API keys to source control. diff --git a/docs/TASKS.md b/docs/TASKS.md index 3e0b568..6eda652 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -68,7 +68,7 @@ requirement is missing and blocks implementation, record it in ## Phase 5 --- Arabic Lyrics Processing - [x] Add Arabic-specific settings UI. -- [ ] Support diacritics/harakat/tanween processing. +- [x] Support diacritics/harakat/tanween processing. - [ ] Preserve intentional spelling/dialect choices where possible. - [ ] Allow user to compare/edit processed Arabic before Suno handoff. - [ ] Add Arabic test fixtures covering multiple dialects.