← 제니앱

악보 기보

음이름과 박자로 가락을 적으면 높은음자리표 악보로 그립니다. 한 음은 음이름·옥타브·박자로 적습니다. 예로 C4/4는 4분음표 도, G4/8은 8분음표 솔, R/4는 4분쉼표입니다. 박자는 1, 2, 4, 8, 16입니다. 변화표는 음이름 뒤에 #이나 b를 붙입니다.

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 글 == "<" { 결과 = 결과 + "&lt;" }
    else if 글 == ">" { 결과 = 결과 + "&gt;" }
    else if 글 == "\"" { 결과 = 결과 + "&quot;" }
    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()))
𝄞