diff --git a/OpenUtau.Plugin.Builtin/KoreanCOCPhonemizer.cs b/OpenUtau.Plugin.Builtin/KoreanCOCPhonemizer.cs
new file mode 100644
index 000000000..4db79f6e3
--- /dev/null
+++ b/OpenUtau.Plugin.Builtin/KoreanCOCPhonemizer.cs
@@ -0,0 +1,501 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OpenUtau.Api;
+using OpenUtau.Core.Ustx;
+using OpenUtau.Core;
+
+namespace OpenUtau.Plugin.KoreanCOC
+{
+ ///
+ /// Phonemizer for 'KOR COC' (Simplified CVVC)
+ ///
+ [Phonemizer("Korean COC Phonemizer", "KO COC", "COCTeam", language: "KO")]
+ public class KoreanCOCPhonemizer : Phonemizer
+ {
+
+ private USinger singer;
+
+ public override void SetSinger(USinger singer)
+ {
+ this.singer = singer;
+ }
+
+ // ==============================================================================
+ // 1. 자음 (First Consonants)
+ // ==============================================================================
+ static readonly Dictionary FIRST_CONSONANTS = new Dictionary(){
+ {"ㄱ", "g"}, {"ㄴ", "n"}, {"ㄷ", "d"}, {"ㄹ", "r"}, {"ㅁ", "m"},
+ {"ㅂ", "b"}, {"ㅅ", "s"}, {"ㅇ", ""}, {"ㅈ", "j"}, {"ㅊ", "ch"},
+ {"ㅋ", "k"}, {"ㅌ", "t"}, {"ㅍ", "p"}, {"ㅎ", "h"}, {"ㄲ", "kk"},
+ {"ㄸ", "tt"}, {"ㅃ", "pp"}, {"ㅉ", "jj"}, {"ㅆ", "ss"},
+ {"null", ""},
+ {"f", "f"}, {"z", "z"}, {"v", "v"}, {"c", "c"}
+ };
+
+ // ==============================================================================
+ // 2. 모음 (Middle Vowels)
+ // ==============================================================================
+ static readonly Dictionary MIDDLE_VOWELS = new Dictionary(){
+ {"ㅏ", new string[3]{"a", "", "a"}}, {"ㅣ", new string[3]{"i", "", "i"}},
+ {"ㅜ", new string[3]{"u", "", "u"}}, {"ㅔ", new string[3]{"e", "", "e"}},
+ {"ㅐ", new string[3]{"e", "", "e"}}, {"ㅗ", new string[3]{"o", "", "o"}},
+ {"ㅡ", new string[3]{"eu", "", "eu"}}, {"ㅓ", new string[3]{"eo", "", "eo"}},
+ {"ㅑ", new string[3]{"ya", "y", "a"}}, {"ㅠ", new string[3]{"yu", "y", "u"}},
+ {"ㅖ", new string[3]{"ye", "y", "e"}}, {"ㅒ", new string[3]{"ye", "y", "e"}},
+ {"ㅛ", new string[3]{"yo", "y", "o"}}, {"ㅕ", new string[3]{"yeo", "y", "eo"}},
+ {"ㅘ", new string[3]{"wa", "w", "a"}}, {"ㅟ", new string[3]{"wi", "w", "i"}},
+ {"ㅞ", new string[3]{"we", "w", "e"}}, {"ㅚ", new string[3]{"we", "w", "e"}},
+ {"ㅙ", new string[3]{"we", "w", "e"}}, {"ㅝ", new string[3]{"weo", "w", "eo"}},
+ {"ㅢ", new string[3]{"eui", "", "i"}},
+ {"null", new string[3]{"", "", ""}}
+ };
+
+ // ==============================================================================
+ // 3. 종성 (Last Consonants)
+ // ==============================================================================
+ static readonly Dictionary LAST_CONSONANTS = new Dictionary(){
+ {"ㄱ", new string[]{"K", ""}}, {"ㄴ", new string[]{"N", ""}}, {"ㄷ", new string[]{"T", ""}},
+ {"ㄹ", new string[]{"L", ""}}, {"ㅁ", new string[]{"M", ""}}, {"ㅂ", new string[]{"P", ""}},
+ {"ㅇ", new string[]{"NG", ""}},
+ {"ㄲ", new string[]{"K", ""}}, {"ㄳ", new string[]{"K", ""}}, {"ㄵ", new string[]{"N", ""}},
+ {"ㄶ", new string[]{"N", ""}}, {"ㄺ", new string[]{"K", ""}}, {"ㄻ", new string[]{"M", ""}},
+ {"ㄼ", new string[]{"L", ""}}, {"ㄽ", new string[]{"L", ""}}, {"ㄾ", new string[]{"L", ""}},
+ {"ㄿ", new string[]{"P", ""}}, {"ㅀ", new string[]{"L", ""}}, {"ㅄ", new string[]{"P", ""}},
+ {"ㅅ", new string[]{"T", ""}}, {"ㅆ", new string[]{"T", ""}}, {"ㅈ", new string[]{"T", ""}},
+ {"ㅊ", new string[]{"T", ""}}, {"ㅋ", new string[]{"K", ""}}, {"ㅌ", new string[]{"T", ""}},
+ {"ㅍ", new string[]{"P", ""}}, {"ㅎ", new string[]{"T", ""}},
+ {" ", new string[]{"", ""}}, {"null", new string[]{"", ""}}
+ };
+
+ // ==============================================================================
+ // 4. 한글 처리 유틸리티
+ // ==============================================================================
+ private static class KoreanParser
+ {
+ private static readonly char[] CHOSUNG = {
+ 'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
+ 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
+ };
+ private static readonly char[] JUNGSUNG = {
+ 'ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ',
+ 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'
+ };
+ private static readonly char[] JONGSUNG = {
+ ' ', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ',
+ 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ',
+ 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
+ };
+
+ public static bool IsHangeul(string text)
+ {
+ if (string.IsNullOrEmpty(text)) return false;
+ char c = text[0];
+ return c >= 0xAC00 && c <= 0xD7A3;
+ }
+
+ public static string[] Separate(string text)
+ {
+ if (!IsHangeul(text)) return new string[] { text, "null", "null" };
+
+ char c = text[0];
+ int code = c - 0xAC00;
+ int jong = code % 28;
+ int jung = (code / 28) % 21;
+ int cho = code / 28 / 21;
+
+ string sCho = CHOSUNG[cho].ToString();
+ string sJung = JUNGSUNG[jung].ToString();
+ string sJong = JONGSUNG[jong] == ' ' ? " " : JONGSUNG[jong].ToString();
+
+ return new string[] { sCho, sJung, sJong };
+ }
+ }
+
+ private string NormalizeJamo(string input)
+ {
+ if (string.IsNullOrEmpty(input) || input.Length != 1) return input;
+ char c = input[0];
+ if (c >= 0x3131 && c <= 0x314E) return input;
+ return input;
+ }
+
+ private string[] ParseRomaji(string lyric)
+ {
+ if (string.IsNullOrEmpty(lyric)) { return new string[] { "null", "null", "null" }; }
+
+ string[] doubleConsonants = { "kk", "tt", "pp", "jj", "ss", "ch", "ng", "th", "ph", "gh", "wh" };
+ foreach (var c in doubleConsonants)
+ {
+ if (lyric.StartsWith(c))
+ {
+ string vowel = lyric.Substring(c.Length);
+ return new string[] { c, vowel, " " };
+ }
+ }
+
+ string[] singleConsonants = { "k", "t", "p", "n", "m", "s", "r", "l", "g", "d", "b", "j", "h", "f", "v", "z", "c", "w", "y" };
+ foreach (var c in singleConsonants)
+ {
+ if (lyric.StartsWith(c))
+ {
+ string vowel = lyric.Substring(c.Length);
+ return new string[] { c, vowel, " " };
+ }
+ }
+
+ return new string[] { "", lyric, " " };
+ }
+
+ // [복구 완료] IsRomanized 메서드 추가 (CS0103 오류 해결)
+ private bool IsRomanized(string phoneme)
+ {
+ if (string.IsNullOrEmpty(phoneme)) { return false; }
+ string[] validStarts = {
+ "kk", "tt", "pp", "jj", "ss", "ch", "ng", "th", "ph", "gh", "wh",
+ "k", "t", "p", "n", "m", "s", "r", "l", "g", "d", "b", "j", "h", "f", "v", "z", "c", "w", "y",
+ "a", "e", "i", "o", "u"
+ };
+ foreach (var start in validStarts)
+ {
+ if (phoneme.StartsWith(start)) return true;
+ }
+ return false;
+ }
+
+ // ==============================================================================
+ // [중요] RomajiCVVCPhonemizer 스타일의 Helper 메서드 이식
+ // note.color 대신 note.phonemeAttributes를 사용하여 API 호환성 및 접미사 문제 해결
+ // ==============================================================================
+
+ // OTO가 존재하는지 확인하는 함수 (접미사/Color 고려)
+ private bool CheckOto(Note note, string phoneme)
+ {
+ if (singer == null || string.IsNullOrEmpty(phoneme)) return false;
+
+ // Romaji 예시처럼 phonemeAttributes에서 정보 추출
+ var attr = note.phonemeAttributes?.FirstOrDefault(attr => attr.index == 0) ?? default;
+
+ // alternate(특수 기호 등)가 있으면 붙여서 먼저 시도
+ string inputWithAlt = phoneme + attr.alternate;
+ if (singer.TryGetMappedOto(inputWithAlt, note.tone + attr.toneShift, attr.voiceColor, out var _))
+ {
+ return true;
+ }
+
+ // 없으면 기본 음소로 시도
+ return singer.TryGetMappedOto(phoneme, note.tone + attr.toneShift, attr.voiceColor, out var _);
+ }
+
+ // Phoneme 객체를 생성하는 함수 (접미사 적용된 Alias 반환)
+ private Phoneme CreatePhoneme(Note note, string phoneme, int position)
+ {
+ var attr = note.phonemeAttributes?.FirstOrDefault(attr => attr.index == 0) ?? default;
+
+ // 1. Alternate 적용 시도
+ string inputWithAlt = phoneme + attr.alternate;
+ if (singer.TryGetMappedOto(inputWithAlt, note.tone + attr.toneShift, attr.voiceColor, out var otoAlt))
+ {
+ return new Phoneme { phoneme = otoAlt.Alias, position = position };
+ }
+
+ // 2. 기본 음소 시도 (Color 적용됨)
+ if (singer.TryGetMappedOto(phoneme, note.tone + attr.toneShift, attr.voiceColor, out var oto))
+ {
+ return new Phoneme { phoneme = oto.Alias, position = position };
+ }
+
+ // 3. 실패 시 입력 그대로 반환
+ return new Phoneme { phoneme = phoneme, position = position };
+ }
+
+ // ==============================================================================
+ // 핵심 로직: COC 변환
+ // ==============================================================================
+ private Result ConvertForCOC(Note[] notes, string[] prevLyric, string[] thisLyric, string[] nextLyric, Note? nextNeighbour)
+ {
+ int totalDuration = notes.Sum(n => n.duration); // Ticks 단위
+ Note note = notes[0];
+
+ string thisMidVowelTail;
+
+ string soundBeforeEndSound = (thisLyric.Length > 2 && thisLyric[2] == " ") ? thisLyric[1] : thisLyric[2];
+ if (string.IsNullOrEmpty(soundBeforeEndSound)) soundBeforeEndSound = "null";
+
+ string thisMidVowelForEnd;
+ if (MIDDLE_VOWELS.ContainsKey(soundBeforeEndSound))
+ {
+ thisMidVowelForEnd = MIDDLE_VOWELS[soundBeforeEndSound][2];
+ }
+ else if (LAST_CONSONANTS.ContainsKey(soundBeforeEndSound))
+ {
+ thisMidVowelForEnd = LAST_CONSONANTS[soundBeforeEndSound][0];
+ }
+ else
+ {
+ thisMidVowelForEnd = soundBeforeEndSound;
+ }
+ string endSound = $"{thisMidVowelForEnd} R";
+
+ bool isItNeedsFrontCV = prevLyric[0] == "null" || prevLyric[1] == "null";
+ bool isItNeedsEndSound = (nextLyric[0] == "null" || nextLyric[1] == "null") && nextNeighbour == null;
+
+ if (thisLyric.All(part => part == null))
+ {
+ return new Result { phonemes = new Phoneme[] { CreatePhoneme(note, note.lyric, 0) } };
+ }
+ else
+ {
+ if (MIDDLE_VOWELS.ContainsKey(thisLyric[1]))
+ {
+ thisMidVowelTail = $"{MIDDLE_VOWELS[thisLyric[1]][2]}";
+ }
+ else
+ {
+ thisMidVowelTail = thisLyric[1];
+ }
+ }
+
+ // 1. CV 생성
+ string CV;
+ string currConsonantHangul = NormalizeJamo(thisLyric[0]);
+ string prevBatchimHangul = NormalizeJamo(prevLyric[2]);
+
+ string currentConsonantSymbol = FIRST_CONSONANTS.ContainsKey(currConsonantHangul) ? FIRST_CONSONANTS[currConsonantHangul] : currConsonantHangul;
+
+ if (currConsonantHangul == "ㄹ" && prevBatchimHangul == "ㄹ")
+ {
+ currentConsonantSymbol = "l";
+ string[] diphthongs = { "ㅑ", "ㅕ", "ㅛ", "ㅠ", "ㅖ", "ㅒ", "ㅘ", "ㅙ", "ㅚ", "ㅝ", "ㅞ", "ㅟ", "ㅢ" };
+ if (diphthongs.Contains(thisLyric[1]))
+ {
+ currentConsonantSymbol = "";
+ }
+ }
+
+ string currentVowelSymbol = MIDDLE_VOWELS.ContainsKey(thisLyric[1]) ? MIDDLE_VOWELS[thisLyric[1]][0] : thisLyric[1];
+ CV = $"{currentConsonantSymbol}{currentVowelSymbol}";
+
+ if (currentConsonantSymbol == "")
+ {
+ if (prevLyric[0] != "null")
+ {
+ string cvStar = $"{CV} *";
+ if (CheckOto(note, cvStar))
+ {
+ CV = cvStar;
+ }
+ }
+ }
+
+ string frontCV = $"- {CV}";
+ if (!CheckOto(note, frontCV))
+ {
+ frontCV = $"-{CV}";
+ if (!CheckOto(note, frontCV))
+ {
+ frontCV = CV;
+ }
+ }
+
+ // ==========================================================================
+ // 2. VC 생성
+ // ==========================================================================
+ string VC = null;
+ bool isItNeedsVC = thisLyric[2] == " " && nextLyric[0] != "null";
+
+ if (isItNeedsVC)
+ {
+ string nextInput = NormalizeJamo(nextLyric[0]);
+ string nextConsonantSymbol = "";
+ bool isSpaceNeeded = true;
+
+ string rawSymbol = nextInput;
+ if (FIRST_CONSONANTS.ContainsKey(nextInput))
+ {
+ rawSymbol = FIRST_CONSONANTS[nextInput];
+ }
+
+ switch (rawSymbol)
+ {
+ case "kk": nextConsonantSymbol = "K"; isSpaceNeeded = false; break;
+ case "tt": nextConsonantSymbol = "T"; isSpaceNeeded = false; break;
+ case "pp": nextConsonantSymbol = "P"; isSpaceNeeded = false; break;
+ case "jj": nextConsonantSymbol = "T"; isSpaceNeeded = false; break;
+ case "ss": nextConsonantSymbol = "s"; isSpaceNeeded = true; break;
+ case "k": nextConsonantSymbol = "g"; break;
+ case "t": nextConsonantSymbol = "d"; break;
+ case "p": nextConsonantSymbol = "b"; break;
+ case "j": nextConsonantSymbol = "d"; break;
+ case "ch": nextConsonantSymbol = "d"; break;
+ case "n": nextConsonantSymbol = "N"; isSpaceNeeded = false; break;
+ case "m": nextConsonantSymbol = "M"; isSpaceNeeded = false; break;
+ default: nextConsonantSymbol = rawSymbol; break;
+ }
+
+ if (!string.IsNullOrEmpty(nextConsonantSymbol) && nextConsonantSymbol.All(char.IsUpper))
+ {
+ isSpaceNeeded = false;
+ }
+
+ if (isSpaceNeeded)
+ {
+ VC = $"{thisMidVowelTail} {nextConsonantSymbol}";
+ }
+ else
+ {
+ VC = $"{thisMidVowelTail}{nextConsonantSymbol}";
+ }
+ }
+
+ // 3. 종성(Batchim) 생성
+ string batchim = null;
+ if (thisLyric[2] != " ")
+ {
+ string batchimSymbol = LAST_CONSONANTS.ContainsKey(thisLyric[2]) ? LAST_CONSONANTS[thisLyric[2]][0] : "";
+ batchim = $"{thisMidVowelTail}{batchimSymbol}";
+ }
+
+ // 4. 결과 반환 (타이밍 계산: r 계열 30 ticks 적용)
+ Result CreateResult(string p1, string p2 = null, int tailLen = 120)
+ {
+ if (p2 == null)
+ {
+ return new Result { phonemes = new Phoneme[] { CreatePhoneme(note, p1, 0) } };
+ }
+
+ // p2가 'r' 또는 'R'로 끝나면 tailLen을 30으로 축소 (뒤로 밀기)
+ if (p2.EndsWith(" R") || p2.EndsWith(" r"))
+ {
+ tailLen = 30;
+ }
+
+ int actualTailLen = tailLen;
+ if (totalDuration < tailLen * 2)
+ {
+ actualTailLen = totalDuration / 2;
+ }
+
+ return new Result
+ {
+ phonemes = new Phoneme[] {
+ CreatePhoneme(note, p1, 0),
+ CreatePhoneme(note, p2, totalDuration - actualTailLen)
+ }
+ };
+ }
+
+ if (thisLyric[2] == " ")
+ { // 받침 없음
+ if (isItNeedsVC && CheckOto(note, VC))
+ {
+ if (isItNeedsFrontCV)
+ {
+ return CreateResult(frontCV, VC, 120);
+ }
+ return CreateResult(CV, VC, 120);
+ }
+
+ if (isItNeedsFrontCV)
+ {
+ return isItNeedsEndSound ?
+ CreateResult(frontCV, endSound, 120)
+ : CreateResult(frontCV);
+ }
+ return isItNeedsEndSound ?
+ CreateResult(CV, endSound, 120)
+ : CreateResult(CV);
+ }
+
+ // 받침 있음
+ if (isItNeedsFrontCV)
+ {
+ return CreateResult(frontCV, batchim, 120);
+ }
+ return CreateResult(CV, batchim, 120);
+ }
+
+ // ==============================================================================
+ // MAIN ENTRY POINT
+ // ==============================================================================
+ public override Result Process(Note[] notes, Note? prev, Note? next, Note? prevNeighbour, Note? nextNeighbour, Note[] prevNeighbours)
+ {
+ Note note = notes[0];
+
+ // rr 처리
+ if (note.lyric == "rr" && prevNeighbour != null)
+ {
+ string prevVowelTail = "";
+ if (KoreanParser.IsHangeul(prevNeighbour.Value.lyric))
+ {
+ string[] prevLyrics = KoreanParser.Separate(prevNeighbour.Value.lyric);
+ string prevMid = prevLyrics[1];
+ if (MIDDLE_VOWELS.ContainsKey(prevMid))
+ {
+ prevVowelTail = MIDDLE_VOWELS[prevMid][2];
+ }
+ }
+ else
+ {
+ string[] parsed = ParseRomaji(prevNeighbour.Value.lyric);
+ prevVowelTail = parsed[1];
+ }
+
+ if (!string.IsNullOrEmpty(prevVowelTail))
+ {
+ if (CheckOto(note, $"{prevVowelTail} rr"))
+ return new Result { phonemes = new Phoneme[] { CreatePhoneme(note, $"{prevVowelTail} rr", 0) } };
+ }
+ return new Result { phonemes = new Phoneme[] { CreatePhoneme(note, "rr", 0) } };
+ }
+
+ if (!KoreanParser.IsHangeul(note.lyric) && !IsRomanized(note.lyric))
+ {
+ return new Result { phonemes = new Phoneme[] { CreatePhoneme(note, note.lyric, 0) } };
+ }
+
+ string[] prevLyric = new string[] { "null", "null", "null" };
+ if (prevNeighbour != null)
+ {
+ if (KoreanParser.IsHangeul(prevNeighbour.Value.lyric))
+ {
+ prevLyric = KoreanParser.Separate(prevNeighbour.Value.lyric);
+ }
+ else
+ {
+ prevLyric = ParseRomaji(prevNeighbour.Value.lyric);
+ }
+ }
+
+ string[] thisLyric = new string[] { "null", "null", "null" };
+ if (KoreanParser.IsHangeul(note.lyric))
+ {
+ thisLyric = KoreanParser.Separate(note.lyric);
+ }
+ else
+ {
+ thisLyric = ParseRomaji(note.lyric);
+ }
+
+ string[] nextLyric = new string[] { "null", "null", "null" };
+ if (nextNeighbour != null)
+ {
+ if (KoreanParser.IsHangeul(nextNeighbour.Value.lyric))
+ {
+ nextLyric = KoreanParser.Separate(nextNeighbour.Value.lyric);
+ }
+ else
+ {
+ nextLyric = ParseRomaji(nextNeighbour.Value.lyric);
+ }
+ }
+
+ if (thisLyric[0] == "null")
+ {
+ return new Result { phonemes = new Phoneme[] { CreatePhoneme(note, note.lyric, 0) } };
+ }
+
+ return ConvertForCOC(notes, prevLyric, thisLyric, nextLyric, nextNeighbour);
+ }
+ }
+}
\ No newline at end of file