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, 쉼표=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/accidental tags. Raw tokens stay at the input boundary until parsed.
type 음이름값 = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "R"
type 변화표 = "" | "#" | "b"
type 음표 = {
쉼표: bool, 음이름: 음이름값, 올림: 변화표, 옥타브: int, 박자: int,
다이아: int, 가로: float, 세로: float, 스템위: bool
}
type 그룹 = { 시작: int, 끝: int }
// 레코드 리터럴은 문자열 리터럴을 넓히므로 음표 생성은 헬퍼에서 좁힌다.
// Record literals widen string literals, so note records narrow through this helper.
function 음표만들기(쉼표: bool, 음이름: 음이름값, 올림: 변화표, 옥타브: int, 박자: int, 다이아: int, 가로: float, 세로: float, 스템위: bool) -> 음표 {
{ 쉼표: 쉼표, 음이름: 음이름, 올림: 올림, 옥타브: 옥타브, 박자: 박자, 다이아: 다이아, 가로: 가로, 세로: 세로, 스템위: 스템위 }
}
// HTML 이스케이프. 토큰은 사용자 입력이라 출력 전에 반드시 이스케이프한다.
// HTML escape. Tokens are user input, so always escape before output.
function 이스케이프(원문: string) -> string {
let mut 결과 = ""
for 글 in 원문.scalars() {
if 글 == "&" { 결과 = 결과 + "&" }
else if 글 == "<" { 결과 = 결과 + "<" }
else if 글 == ">" { 결과 = 결과 + ">" }
else if 글 == "\"" { 결과 = 결과 + """ }
else { 결과 = 결과 + 글 }
}
결과
}
// 음이름을 다이아토닉 색인으로(C=0..B=6). 아니면 -1.
// Letter to diatonic index (C=0..B=6). -1 if not a note letter.
function 음이름색인(글: 음이름값) -> int {
match 글 {
case "C" => 0
case "D" => 1
case "E" => 2
case "F" => 3
case "G" => 4
case "A" => 5
case "B" => 6
case "R" => -1
}
}
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 "R" => Ok("R")
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 mut 박자 = 0
if 박자글 == "1" { 박자 = 1 }
else if 박자글 == "2" { 박자 = 2 }
else if 박자글 == "4" { 박자 = 4 }
else if 박자글 == "8" { 박자 = 8 }
else if 박자글 == "16" { 박자 = 16 }
else {
return Err("{번호}번째 토큰 '" + 이스케이프(토큰) + "': 박자는 1, 2, 4, 8, 16 중 하나여야 합니다")
}
let 음높이 = 쪽[0]
if 음높이 == "R" {
return Ok(음표만들기(true, "R", "", 0, 박자, 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 색인 = 음이름색인(이름값)
if 색인 < 0 {
return Err("{번호}번째 토큰 '" + 이스케이프(토큰) + "': 음이름은 A~G여야 합니다")
}
let mut 올림: 변화표 = ""
let mut 옥글 = 글자들[1]
if 글자들.length == 3 {
let 표 = 글자들[1]
if 표 == "#" {
올림 = "#"
} else if 표 == "b" {
올림 = "b"
} else {
return Err("{번호}번째 토큰 '" + 이스케이프(토큰) + "': 변화표는 # 또는 b여야 합니다")
}
옥글 = 글자들[2]
}
let 옥타브 = 숫자값(옥글)
if 옥타브 < 0 {
return Err("{번호}번째 토큰 '" + 이스케이프(토큰) + "': 옥타브는 한 자리 숫자여야 합니다")
}
let 다이아 = 옥타브 * 7 + 색인
Ok(음표만들기(false, 이름값, 올림, 옥타브, 박자, 다이아, 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 {
if 박자 == 1 { fromCodePoint(119611) ?? "" }
else if 박자 == 2 { fromCodePoint(119612) ?? "" }
else if 박자 == 4 { fromCodePoint(119613) ?? "" }
else if 박자 == 8 { fromCodePoint(119614) ?? "" }
else { 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 토큰들 = filter(입력.trim().split(" "), 토 => 토 != "")
let mut 음표들: Array<음표> = []
let mut 오류 = ""
let mut 번호 = 1
for 토 in 토큰들 {
if 오류 == "" {
match 토큰파싱(토, 번호) {
case Ok(음) => { 음표들.push(음) }
case Err(메) => { 오류 = 메 }
}
}
번호 = 번호 + 1
}
if 오류 != "" { Err(오류) } else { 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
let mut 색인 = 0
while 색인 < 음표들.length {
let 음 = 음표들[색인]
let 빔가능 = if 음.쉼표 { false } else { 음.박자 == 8 || 음.박자 == 16 }
if 빔가능 {
if 시작 < 0 { 시작 = 색인 }
} else {
if 시작 >= 0 { 그룹들.push({ 시작: 시작, 끝: 색인 - 1 }); 시작 = -1 }
}
색인 = 색인 + 1
}
if 시작 >= 0 { 그룹들.push({ 시작: 시작, 끝: 음표들.length - 1 }) }
// 크기 2 이상 그룹의 음표는 빔된 배열에 표시한다(스템을 빔까지 그린다).
// Mark notes in groups of 2+ in the beam array (stems drawn to the beam).
let mut 빔된: Array<bool> = []
let mut 자리 = 0
while 자리 < 음표들.length { 빔된.push(false); 자리 = 자리 + 1 }
for 그 in 그룹들 {
if 그.끝 > 그.시작 {
let mut 칸 = 그.시작
while 칸 <= 그.끝 { 빔된[칸] = true; 칸 = 칸 + 1 }
}
}
// 보이는 범위를 항상 그려지는 자리표와 오선부터 잡고 음표 높이로 넓힌다.
// 자리표(기준선 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\">"
let mut 줄 = 0
while 줄 < 5 {
let 와 = 40.0 + toFloat(줄) * 12.0
출력 = 출력 + 선(10.0, 와, 전체너비 - 10.0, 와, "1")
줄 = 줄 + 1
}
출력 = 출력 + 글자(30.0, 86.0, "48", fromCodePoint(119070) ?? "")
// 머리·변화표·보조선·쉼표·홀로 스템.
// Heads, accidentals, ledger lines, rests, and lone stems.
let mut 차례 = 0
while 차례 < 음표들.length {
let 음 = 음표들[차례]
if 음.쉼표 {
출력 = 출력 + 글자(음.가로, 70.0, "30", 쉼표글(음.박자))
} else {
출력 = 출력 + 보조선(음.다이아, 음.가로)
if 음.올림 != "" {
let 표 = if 음.올림 == "#" { fromCodePoint(9839) ?? "" } else { fromCodePoint(9837) ?? "" }
출력 = 출력 + 글자(음.가로 - 11.0, 음.세로 + 4.0, "18", 표)
}
let 채움 = 음.박자 != 1 && 음.박자 != 2
출력 = 출력 + 타원(음.가로, 음.세로, 채움)
// 온음표는 스템이 없다. 빔된 음표의 스템은 빔 단계에서 그린다.
// Whole notes have no stem. Beamed notes' stems are drawn in the beam pass.
if 음.박자 != 1 && !빔된[차례] {
if 음.스템위 {
출력 = 출력 + 선(음.가로 + 5.0, 음.세로, 음.가로 + 5.0, 음.세로 - 스템길이, "1.5")
} else {
출력 = 출력 + 선(음.가로 - 5.0, 음.세로, 음.가로 - 5.0, 음.세로 + 스템길이, "1.5")
}
if 음.박자 == 8 || 음.박자 == 16 {
출력 = 출력 + 깃발(음.가로, 음.세로, 음.박자, 음.스템위, 스템길이)
}
}
}
차례 = 차례 + 1
}
// 빔(크기 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
let mut 합칸 = 그.시작
while 합칸 <= 그.끝 { 합 = 합 + 음표들[합칸].다이아; 합칸 = 합칸 + 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.
let mut 걸음 = 그.시작
while 걸음 <= 그.끝 {
let 음 = 음표들[걸음]
let 엑 = 음.가로 + 스템엑
let 빔와 = 첫끝 + 기울기 * (엑 - 첫엑)
출력 = 출력 + 선(엑, 음.세로, 엑, 빔와, "1.5")
걸음 = 걸음 + 1
}
출력 = 출력 + 선(첫엑, 첫끝, 끝엑, 끝끝, "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()))