From 6a99266e755c53ac6e6472fae397efb1ee51bf07 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 13 Sep 2026 22:42:50 +0200 Subject: [PATCH] Add Arabic lyrics review --- .../Presentation/ArabicLyricsReviewView.swift | 108 ++++++++++++++++++ .../Presentation/FinalReviewView.swift | 4 + .../Domain/SongProject.swift | 9 ++ .../SongProjectModelTests.swift | 30 +++++ docs/ARCHITECTURE.md | 4 +- docs/TASKS.md | 2 +- 6 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 Sources/MusicAssistantApp/Presentation/ArabicLyricsReviewView.swift diff --git a/Sources/MusicAssistantApp/Presentation/ArabicLyricsReviewView.swift b/Sources/MusicAssistantApp/Presentation/ArabicLyricsReviewView.swift new file mode 100644 index 0000000..9675351 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/ArabicLyricsReviewView.swift @@ -0,0 +1,108 @@ +import MusicAssistantCore +import SwiftUI + +struct ArabicLyricsReviewView: View { + @Binding var project: SongProject + + private let processor = ArabicPronunciationProcessor() + + @State private var processedLyrics = "" + @State private var processingNotes: [ArabicPronunciationProcessingNote] = [] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Arabic lyrics review") + .font(.headline) + + Spacer() + + Button(action: refreshProcessedLyrics) { + Label("Refresh processed lyrics", systemImage: "arrow.clockwise") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .help("Refresh processed lyrics") + } + + Text("Original lyrics") + .font(.subheadline) + .foregroundStyle(.secondary) + + TextEditor(text: .constant(project.lyrics.text)) + .font(.body) + .frame(minHeight: 96) + .disabled(true) + + Text("Processed lyrics") + .font(.subheadline) + .foregroundStyle(.secondary) + + TextEditor(text: $processedLyrics) + .font(.body) + .frame(minHeight: 96) + + ForEach(Array(processingNotes.enumerated()), id: \.offset) { entry in + Label(entry.element.title, systemImage: entry.element.systemImage) + .font(.caption) + .foregroundStyle(.secondary) + } + + Button(action: applyProcessedLyrics) { + Label("Use processed lyrics", systemImage: "checkmark.circle") + } + .disabled(!canApplyProcessedLyrics) + } + .onAppear(perform: refreshProcessedLyrics) + } + + private var canApplyProcessedLyrics: Bool { + let trimmedLyrics = processedLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmedLyrics.isEmpty && processedLyrics != project.lyrics.text + } + + private func refreshProcessedLyrics() { + let result = processor.process( + lyrics: project.lyrics.text, + settings: project.arabicPronunciationSettings + ) + processedLyrics = result.text + processingNotes = result.notes + } + + private func applyProcessedLyrics() { + project.applyReviewedArabicLyrics(processedLyrics) + } +} + +private extension ArabicPronunciationProcessingNote { + var title: String { + switch self { + case .processingDisabled: + return "Arabic processing is disabled" + case .diacritizationPolicyUnspecified: + return "Select a diacritics policy to continue" + case .pronunciationTargetedDiacriticsNeedReview: + return "Targeted diacritics need review" + case .fullTashkeelNeedsReview: + return "Full tashkeel needs review" + case .tanweenAdditionNeedsReview: + return "Tanween addition needs review" + case let .preservedSpellingsProtected(count): + return "Protected \(count) preserved spelling\(count == 1 ? "" : "s")" + } + } + + var systemImage: String { + switch self { + case .processingDisabled, .diacritizationPolicyUnspecified: + return "info.circle" + case .pronunciationTargetedDiacriticsNeedReview, + .fullTashkeelNeedsReview, + .tanweenAdditionNeedsReview: + return "exclamationmark.triangle" + case .preservedSpellingsProtected: + return "checkmark.shield" + } + } +} diff --git a/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift b/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift index 39d01c5..ebc8d55 100644 --- a/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift +++ b/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift @@ -9,6 +9,10 @@ struct FinalReviewView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { + ArabicLyricsReviewView(project: $project) + + Divider() + TextField("Approved lyrics", text: $project.lyrics.text, axis: .vertical) .lineLimit(4...10) diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index 32000c7..2f6e3e5 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -199,6 +199,15 @@ public extension SongProject { dialects.removeAll { $0.id == dialectID } } + mutating func applyReviewedArabicLyrics(_ reviewedLyrics: String) { + let originalLyrics = lyrics.text + lyrics.text = reviewedLyrics + + if sunoOutput?.lyricsText == originalLyrics { + sunoOutput?.lyricsText = reviewedLyrics + } + } + mutating func setBPMManually(_ value: Int) { bpm = ManualAutoValue(mode: .manual, value: value) } diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift index fe9d478..c901991 100644 --- a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -369,6 +369,36 @@ final class SongProjectModelTests: XCTestCase { XCTAssertEqual(project.languageIDs, ["english"]) } + func testApplyingReviewedArabicLyricsUpdatesMatchingSunoLyrics() { + var project = SongProject( + id: "project-arabic-review", + title: "Arabic Review", + idea: "Review pronunciation", + lyrics: Lyrics(text: "Original lyrics"), + sunoOutput: SunoOutput(lyricsText: "Original lyrics", stylePrompt: "Arabic pop") + ) + + project.applyReviewedArabicLyrics("Reviewed lyrics") + + XCTAssertEqual(project.lyrics.text, "Reviewed lyrics") + XCTAssertEqual(project.sunoOutput?.lyricsText, "Reviewed lyrics") + } + + func testApplyingReviewedArabicLyricsPreservesEditedSunoLyrics() { + var project = SongProject( + id: "project-arabic-review-manual-output", + title: "Arabic Review", + idea: "Review pronunciation", + lyrics: Lyrics(text: "Original lyrics"), + sunoOutput: SunoOutput(lyricsText: "Manually edited output", stylePrompt: "Arabic pop") + ) + + project.applyReviewedArabicLyrics("Reviewed lyrics") + + XCTAssertEqual(project.lyrics.text, "Reviewed lyrics") + XCTAssertEqual(project.sunoOutput?.lyricsText, "Manually edited output") + } + func testMusicalParametersSupportManualAndAutoModes() { var project = SongProject( id: "project-8", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c620bc1..3543aa2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -68,7 +68,9 @@ 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. Before processing, it protects the user's exact preserved spellings and dialect phrases so normalization -or tanween removal cannot alter them. +or tanween removal cannot alter them. The Presentation layer presents +both the original and processed lyrics in final review; the user may edit +and explicitly apply the processed text before any Suno handoff. ## Security diff --git a/docs/TASKS.md b/docs/TASKS.md index ce4f234..33a87cc 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -70,7 +70,7 @@ requirement is missing and blocks implementation, record it in - [x] Add Arabic-specific settings UI. - [x] Support diacritics/harakat/tanween processing. - [x] Preserve intentional spelling/dialect choices where possible. -- [ ] Allow user to compare/edit processed Arabic before Suno handoff. +- [x] Allow user to compare/edit processed Arabic before Suno handoff. - [ ] Add Arabic test fixtures covering multiple dialects. ## Phase 6 --- Instrument Selector & Catalog