music.tpz
// 악보 기보. 작은 DSL(예: C4/4 D4/8)을 받아 높은음자리표 오선에 SVG로 그린다.
// Music notation. Renders a tiny DSL (e.g. C4/4 D4/8) as notation on a treble staff (SVG).
// Key identifiers: 음표=note, 음높이=pitch(쉼 rest/음 note), 쉼표인가=isRest, 음이름=letter, 올림=accidental, 옥타브=octave,
// 박자=duration, 다이아=diatonic step, 가로=x, 세로=y, 스템위=stemUp,
// 토큰파싱=parseToken, 음이름색인=letterIndex, 세로계산=yOfDiatonic, 너비=advance,
// 이스케이프=escape, 선/타원/글자=line/ellipse/text helpers, 그룹=beam group, 처리=render.
//
// 결정적이다. 같은 DSL이면 인터프리터와 빌드에서 같은 SVG가 나온다.
// Deterministic: the same DSL yields the same SVG across the interpreter and the build.
// 안 하는 것: 재생, MIDI, 화음, 다성/다단, 조표, 박자표 검사, 전문 인그레이빙, 잇단음표, 이음줄.
// Won't do: playback, MIDI, chords, multi-voice/staff, key sig, time-sig check, pro engraving, tuplets, ties.
// 가로 간격과 빔 기울기는 toFloat(int)->float로 계산한다(이 앱이 견인한 기능).
// Horizontal spacing and beam slope use toFloat (the compiler feature this app drove).
// 닫힌 음이름 태그. 원문 토큰은 입력 경계라 파싱 지점에서만 좁힌다.
// Closed note-letter tag. Raw tokens stay at the input boundary until parsed.
type 음이름값 = "A" | "B" | "C" | "D" | "E" | "F" | "G"
// 변화표는 닫힌 3택이라 열거형으로 둔다. 빈 문자열 센티넬 없음.
// The accidental is a closed 3-way choice, so it is an enum. No empty-string sentinel.
enum 변화표 { 제자리, 올림표, 내림표 }
// 음높이는 쉼표이거나 (음이름·변화표·옥타브)를 실은 음이다. 쉼표는 페이로드가 없다.
// A pitch is either a rest or a note carrying (letter, accidental, octave). A rest has no payload.
enum 음높이 { 쉼, 음(음이름값, 변화표, int) }
record 음표 {
높이: 음높이, 박자: int,
다이아: int, 가로: float, 세로: float, 스템위: bool
}
record 그룹 { 시작: int, 끝: int }
// 쉼표 여부. 렌더 패스들은 높이 열거형을 쉼표/음으로만 가른다.
// Whether a note is a rest. The render passes only split the pitch enum into rest vs note.
function 쉼표인가(음: 음표) -> bool {
match 음.높이 {
case 쉼 => true
case 음(_, _, _) => false
}
}
// HTML 이스케이프. 토큰은 사용자 입력이라 출력 전에 반드시 이스케이프한다.
// HTML escape. Tokens are user input, so always escape before output.
function 이스케이프(원문: string) -> string {
원문.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
}
// 음이름을 다이아토닉 색인으로(C=0..B=6).
// Letter to diatonic index (C=0..B=6).
function 음이름색인(글: 음이름값) -> int {
match 글 {
case "C" => 0
case "D" => 1
case "E" => 2
case "F" => 3
case "G" => 4
case "A" => 5
case "B" => 6
}
}
function 음이름파싱(글: string) -> Result<음이름값, string> {
match 글 {
case "A" => Ok("A")
case "B" => Ok("B")
case "C" => Ok("C")
case "D" => Ok("D")
case "E" => Ok("E")
case "F" => Ok("F")
case "G" => Ok("G")
case _ => Err("bad")
}
}
// 한 자리 숫자면 그 값, 아니면 -1.
// A single digit's value, or -1.
function 숫자값(글: string) -> int {
let 코드 = 글.codePointAt(0) ?? 0
if 코드 >= 48 && 코드 <= 57 { 코드 - 48 } else { -1 }
}
// 토큰 하나를 음표로 파싱한다. 형식: R/박자 또는 <음이름><#|b?><옥타브>/<박자>.
// Parse one token into a note. Form: R/박자 or <letter><#|b?><octave>/<박자>.
function 토큰파싱(토큰: string, 번호: int) -> Result<음표, string> {
let 쪽 = 토큰.split("/")
if 쪽.length != 2 {
return Err("{번호}번째 토큰 '" + 이스케이프(토큰) + "': 음이름/박자 형식이어야 합니다")
}
let 박자글 = 쪽[1]
let 박자 = match 박자글 {
case "1" => 1
case "2" => 2
case "4" => 4
case "8" => 8
case "16" => 16
case _ => return Err("{번호}번째 토큰 '" + 이스케이프(토큰) + "': 박자는 1, 2, 4, 8, 16 중 하나여야 합니다")
}
let 음높이글 = 쪽[0]
if 음높이글 == "R" {
return Ok(음표 { 높이: 음높이.쉼, 박자: 박자, 다이아: 0, 가로: 0.0, 세로: 0.0, 스템위: false })
}
let 글자들 = 음높이글.scalars()
if 글자들.length < 2 || 글자들.length > 3 {
return Err("{번호}번째 토큰 '" + 이스케이프(토큰) + "': 잘못된 음이름입니다")
}
let 이름값 = match 음이름파싱(글자들[0]) {
case Ok(이름) => 이름
case Err(_) => return Err("{번호}번째 토큰 '" + 이스케이프(토큰) + "': 음이름은 A~G여야 합니다")
}
let 색인 = 음이름색인(이름값)
let mut 올림: 변화표 = 변화표.제자리
let mut 옥글 = 글자들[1]
if 글자들.length == 3 {
let 표 = 글자들[1]
올림 = match 표 {
case "#" => 변화표.올림표
case "b" => 변화표.내림표
case _ => return Err("{번호}번째 토큰 '" + 이스케이프(토큰) + "': 변화표는 # 또는 b여야 합니다")
}
옥글 = 글자들[2]
}
let 옥타브 = 숫자값(옥글)
if 옥타브 < 0 {
return Err("{번호}번째 토큰 '" + 이스케이프(토큰) + "': 옥타브는 한 자리 숫자여야 합니다")
}
let 다이아 = 옥타브 * 7 + 색인
Ok(음표 { 높이: 음높이.음(이름값, 올림, 옥타브), 박자: 박자, 다이아: 다이아, 가로: 0.0, 세로: 0.0, 스템위: false })
}
// 다이아토닉 색인의 세로 좌표. 맨 아랫줄 E4(다이아 30)이 y=88, 한 칸 위로 6px.
// y for a diatonic index. Bottom line E4 (diatonic 30) is y=88, one step up is 6px.
function 세로계산(다이아: int) -> float {
88.0 - toFloat(다이아 - 30) * 6.0
}
// 박자에 비례한 가로 진행폭. 박자가 길수록 넓다(온음표 4박, 16분음표 0.25박).
// Horizontal advance proportional to duration. Longer notes are wider (whole 4 beats, 16th 0.25).
function 너비(박자: int) -> float {
30.0 + 14.0 * (4.0 / toFloat(박자))
}
// SVG 선 하나.
// One SVG line.
function 선(엑1: float, 와1: float, 엑2: float, 와2: float, 두께: string) -> string {
"<line x1=\"{엑1}\" y1=\"{와1}\" x2=\"{엑2}\" y2=\"{와2}\" stroke=\"#111\" stroke-width=\"" + 두께 + "\" />"
}
// SVG 타원 하나(머리). 채움이 참이면 검정, 아니면 흰 속.
// One SVG ellipse (note head). Filled black if 채움, else hollow.
function 타원(엑: float, 와: float, 채움: bool) -> string {
let 색 = if 채움 { "#111" } else { "#fff" }
"<ellipse cx=\"{엑}\" cy=\"{와}\" rx=\"5\" ry=\"4\" fill=\"" + 색 + "\" stroke=\"#111\" stroke-width=\"1.5\" />"
}
// SVG 글자(자리표·쉼표·변화표 같은 고정 글리프).
// One SVG text glyph (clef, rest, accidental: fixed glyphs only).
function 글자(엑: float, 와: float, 크기: string, 내용: string) -> string {
"<text x=\"{엑}\" y=\"{와}\" font-size=\"" + 크기 + "\" text-anchor=\"middle\" fill=\"#111\">" + 내용 + "</text>"
}
// 쉼표 글리프(유니코드 음악 기호).
// Rest glyph (Unicode musical symbols).
function 쉼표글(박자: int) -> string {
match 박자 {
case 1 => fromCodePoint(119611) ?? ""
case 2 => fromCodePoint(119612) ?? ""
case 4 => fromCodePoint(119613) ?? ""
case 8 => fromCodePoint(119614) ?? ""
case _ => fromCodePoint(119615) ?? ""
}
}
// 보조선(머리가 오선 밖일 때). 위/아래로 줄 위치마다 짧은 선을 긋는다.
// Ledger lines when a head is outside the staff: a short line at each line position.
function 보조선(다이아: int, 가로: float) -> string {
let mut 결과 = ""
let mut 위 = 40
while 위 <= 다이아 {
결과 += 선(가로 - 8.0, 세로계산(위), 가로 + 8.0, 세로계산(위), "1")
위 = 위 + 2
}
let mut 아래 = 28
while 아래 >= 다이아 {
결과 += 선(가로 - 8.0, 세로계산(아래), 가로 + 8.0, 세로계산(아래), "1")
아래 = 아래 - 2
}
결과
}
// 깃발(홀로 있는 8·16분음표). 8분은 하나, 16분은 둘.
// Flags for a lone 8th/16th. One for an 8th, two for a 16th.
function 깃발(가로: float, 머리세로: float, 박자: int, 스템위: bool, 길이: float) -> string {
let mut 결과 = ""
if 스템위 {
let 끝 = 머리세로 - 길이
let 엑 = 가로 + 5.0
결과 += 선(엑, 끝, 엑 + 7.0, 끝 + 10.0, "2")
if 박자 == 16 { 결과 += 선(엑, 끝 + 5.0, 엑 + 7.0, 끝 + 15.0, "2") }
} else {
let 끝 = 머리세로 + 길이
let 엑 = 가로 - 5.0
결과 += 선(엑, 끝, 엑 + 7.0, 끝 - 10.0, "2")
if 박자 == 16 { 결과 += 선(엑, 끝 - 5.0, 엑 + 7.0, 끝 - 15.0, "2") }
}
결과
}
// 본문을 토큰으로 나눠 음표 배열을 만들거나 첫 오류를 돌려준다.
// Split the body into tokens and build the note list, or return the first error.
function 파싱하기(입력: string) -> Result<Array<음표>, string> {
let 토큰들 = 입력.trim().split(" ").filter(토 => 토 != "")
let mut 음표들: Array<음표> = []
let mut 번호 = 1
for 토 in 토큰들 {
음표들.push(토큰파싱(토, 번호)?)
번호 = 번호 + 1
}
Ok(음표들)
}
let 스템길이 = 30.0
// 음표 배열을 받아 오선 SVG를 그린다.
// Render the staff SVG from the note list.
function 그리기(원본: Array<음표>) -> string {
// 가로·세로·스템 방향을 채운다.
// Fill in x, y, and stem direction.
let mut 음표들: Array<음표> = []
let mut 엑스 = 56.0
for 음 in 원본 {
let 세 = if 쉼표인가(음) { 64.0 } else { 세로계산(음.다이아) }
let 위로 = 음.다이아 < 34
음표들.push(음표 { ...음, 가로: 엑스, 세로: 세, 스템위: 위로 })
엑스 += 너비(음.박자)
}
let 전체너비 = 엑스 + 24.0
// 빔 그룹: 연속한 비쉼표 8·16분음표.
// Beam groups: consecutive non-rest 8th/16th notes.
let mut 그룹들: Array<그룹> = []
let mut 시작 = -1
for 색인 in 0..<음표들.length {
let 음 = 음표들[색인]
let 빔가능 = if 쉼표인가(음) { false } else { 음.박자 in [8, 16] }
if 빔가능 {
if 시작 < 0 { 시작 = 색인 }
} else {
if 시작 >= 0 { 그룹들.push(그룹 { 시작: 시작, 끝: 색인 - 1 }); 시작 = -1 }
}
}
if 시작 >= 0 { 그룹들.push(그룹 { 시작: 시작, 끝: 음표들.length - 1 }) }
// 크기 2 이상 그룹에 속한 음표의 색인 집합(스템을 빔까지 그린다).
// Set of indices in groups of 2+ (their stems are drawn to the beam).
let mut 빔된: Set<int> = Set.of()
for 그 in 그룹들 {
if 그.끝 > 그.시작 {
for 칸 in 그.시작..<그.끝 + 1 { 빔된.add(칸) }
}
}
// 보이는 범위를 항상 그려지는 자리표와 오선부터 잡고 음표 높이로 넓힌다.
// 자리표(기준선 86, 글자크기 48)는 오선 위아래로 뻗으므로 그 폭을 먼저 확보한다.
// Seed the view box with the always-drawn clef and staff, then widen for note heights.
// The clef (baseline 86, font 48) extends above and below the staff, so reserve its extent first.
let mut 위쪽 = 28.0
let mut 아래쪽 = 108.0
for 음 in 음표들 {
if !쉼표인가(음) {
if 음.세로 - 42.0 < 위쪽 { 위쪽 = 음.세로 - 42.0 }
if 음.세로 + 42.0 > 아래쪽 { 아래쪽 = 음.세로 + 42.0 }
}
}
let 뷰y = 위쪽 - 5.0
let 뷰높이 = (아래쪽 - 위쪽) + 10.0
// 오선과 자리표.
// Staff and clef.
let mut 출력 = "<svg viewBox=\"0 {뷰y} {전체너비} {뷰높이}\" width=\"{전체너비}\" height=\"{뷰높이}\" xmlns=\"http://www.w3.org/2000/svg\" font-family=\"serif\">"
for 줄 in 0..<5 {
let 와 = 40.0 + toFloat(줄) * 12.0
출력 += 선(10.0, 와, 전체너비 - 10.0, 와, "1")
}
출력 += 글자(30.0, 86.0, "48", fromCodePoint(119070) ?? "")
// 머리·변화표·보조선·쉼표·홀로 스템.
// Heads, accidentals, ledger lines, rests, and lone stems.
for 차례 in 0..<음표들.length {
let 음 = 음표들[차례]
match 음.높이 {
case 쉼 => {
출력 += 글자(음.가로, 70.0, "30", 쉼표글(음.박자))
}
case 음(_, 올림, _) => {
출력 += 보조선(음.다이아, 음.가로)
// 제자리는 글리프를 그리지 않으므로 빈 문자열로 남겨 두고 그릴 때만 낸다.
// A natural draws no glyph, so it stays empty and only accidentals emit a text.
let 표 = match 올림 {
case 제자리 => ""
case 올림표 => fromCodePoint(9839) ?? ""
case 내림표 => fromCodePoint(9837) ?? ""
}
if 표 != "" {
출력 += 글자(음.가로 - 11.0, 음.세로 + 4.0, "18", 표)
}
let 채움 = 음.박자 != 1 && 음.박자 != 2
출력 += 타원(음.가로, 음.세로, 채움)
// 온음표는 스템이 없다. 빔된 음표의 스템은 빔 단계에서 그린다.
// Whole notes have no stem. Beamed notes' stems are drawn in the beam pass.
if 음.박자 != 1 && !(차례 in 빔된) {
if 음.스템위 {
출력 += 선(음.가로 + 5.0, 음.세로, 음.가로 + 5.0, 음.세로 - 스템길이, "1.5")
} else {
출력 += 선(음.가로 - 5.0, 음.세로, 음.가로 - 5.0, 음.세로 + 스템길이, "1.5")
}
if 음.박자 in [8, 16] {
출력 += 깃발(음.가로, 음.세로, 음.박자, 음.스템위, 스템길이)
}
}
}
}
}
// 빔(크기 2 이상 그룹). 첫·끝 스템 끝을 직선으로 잇고 기울기는 toFloat로 구한다.
// Beams (groups of 2+). Join first and last stem tips with a line; slope via toFloat.
for 그 in 그룹들 {
if 그.끝 > 그.시작 {
// 그룹 스템 방향: 평균 다이아가 34 이상이면 아래.
// Group stem direction: down if the average diatonic is >= 34.
let mut 합 = 0
for 합칸 in 그.시작..<그.끝 + 1 { 합 = 합 + 음표들[합칸].다이아 }
let 개수 = 그.끝 - 그.시작 + 1
let 위로 = toFloat(합) < toFloat(34 * 개수)
let 첫 = 음표들[그.시작]
let 끝음 = 음표들[그.끝]
let 스템엑 = if 위로 { 5.0 } else { -5.0 }
let 첫엑 = 첫.가로 + 스템엑
let 끝엑 = 끝음.가로 + 스템엑
let 첫끝 = if 위로 { 첫.세로 - 스템길이 } else { 첫.세로 + 스템길이 }
let 끝끝 = if 위로 { 끝음.세로 - 스템길이 } else { 끝음.세로 + 스템길이 }
let 기울기 = (끝끝 - 첫끝) / (끝엑 - 첫엑)
// 각 음표의 스템을 빔 선까지 그린다.
// Draw each note's stem up to the beam line.
for 걸음 in 그.시작..<그.끝 + 1 {
let 음 = 음표들[걸음]
let 엑 = 음.가로 + 스템엑
let 빔와 = 첫끝 + 기울기 * (엑 - 첫엑)
출력 += 선(엑, 음.세로, 엑, 빔와, "1.5")
}
출력 += 선(첫엑, 첫끝, 끝엑, 끝끝, "4")
// 연속 16분음표 구간마다 보조 빔을 잇는다(단독이면 안쪽으로 짧은 토막).
// Connect a secondary beam across each run of consecutive 16th notes (an inward stub if alone).
let 보조오프셋 = if 위로 { 5.0 } else { -5.0 }
let mut 보조시작 = 그.시작
while 보조시작 <= 그.끝 {
if 음표들[보조시작].박자 == 16 {
let mut 보조끝 = 보조시작
while 보조끝 + 1 <= 그.끝 && 음표들[보조끝 + 1].박자 == 16 { 보조끝 = 보조끝 + 1 }
if 보조끝 > 보조시작 {
let 보엑1 = 음표들[보조시작].가로 + 스템엑
let 보엑2 = 음표들[보조끝].가로 + 스템엑
let 보와1 = (첫끝 + 기울기 * (보엑1 - 첫엑)) + 보조오프셋
let 보와2 = (첫끝 + 기울기 * (보엑2 - 첫엑)) + 보조오프셋
출력 += 선(보엑1, 보와1, 보엑2, 보와2, "3")
} else {
let 보엑 = 음표들[보조시작].가로 + 스템엑
let 보와 = (첫끝 + 기울기 * (보엑 - 첫엑)) + 보조오프셋
if 보조시작 < 그.끝 {
출력 += 선(보엑, 보와, 보엑 + 8.0, 보와 + 기울기 * 8.0, "3")
} else {
출력 += 선(보엑 - 8.0, 보와 - 기울기 * 8.0, 보엑, 보와, "3")
}
}
보조시작 = 보조끝 + 1
} else {
보조시작 = 보조시작 + 1
}
}
}
}
출력 += "</svg>"
출력
}
// 입력을 처리해 메시지 또는 오선 SVG를 돌려준다.
// Process the input and return a message or the staff SVG.
function 처리(입력: string) -> string {
match 파싱하기(입력) {
case Err(메) => { "<div class=\"err\">" + 메 + "</div>" }
case Ok(음표들) => {
if 음표들.length == 0 {
"<div class=\"meta\">가락을 입력하세요. 예: C4/4 D4/4 E4/8 E4/8 G4/2</div>"
} else {
"<div class=\"out\">" + 그리기(음표들) + "</div>"
}
}
}
}
print(처리(input()))