STUDIO HAZE · jeniapp
Topaz source
Open by house policy. Topaz is our own language.
Topaz 5.20.2 · 63 files
apps/music/music.tpz
Topaz · 13489 B · Original file
1
// 악보 기보. 음이름과 박자로 적은 가락을 높은음자리표 오선 위의 SVG 악보로 그립니다.2
// 입력은 공백으로 나눈 토큰이고 토큰은 C4/4, C#5/8, Db4/16 같은 음표나 R/4 같은 쉼표입니다. 박자는 1, 2, 4, 8, 16 입니다.3
// 출력은 SVG 악보를 담은 HTML 조각이며, 잘못된 토큰이 있으면 첫 토큰을 이스케이프해 알리는 오류 조각입니다.4
// 같은 입력이면 인터프리터와 웹 빌드에서 바이트까지 같은 출력을 냅니다.5
// 재생, MIDI, 화음, 여러 성부와 여러 단, 조표, 박자표 검사, 잇단음표, 붙임줄은 다루지 않습니다.6
// Music notation: draws a melody of note/duration tokens on a treble staff as SVG.8
enum 변화표 { 제자리, 올림표, 내림표 }9
// 음계위치는 옥타브 * 7 + 음이름 색인(C 0 ~ B 6)이라 1 이 오선의 줄과 칸 한 단계입니다.10
enum 음높이 { 쉼, 소리(int, 변화표) }11
record 음표 { 높이: 음높이, 박자: int }12
record 놓인음표 { 음: 음표, 가로: float, 세로: float }13
record 구간 { 시작: int, 끝: int }14
record 들보선 { x: float, y: float, 기울기: float }16
let 기둥길이 = 30.017
// 가운데 줄 B4 입니다. 기둥 방향을 가르는 기준이고 쉼표도 이 자리에 둡니다.18
let 가운데줄 = 3419
let 빈입력안내 = "<div class=\"meta\">가락을 입력하세요. 예: C4/4 D4/4 E4/8 E4/8 G4/2</div>"21
// 공용 도우미 시작 (이스케이프 숫자인가 결과풀기)22
// 여러 도구가 같은 원본을 씁니다. 여기서 고치지 않고 원본에서 고친 뒤 다시 생성합니다.24
// 사용자 입력을 HTML 에 넣기 전에 반드시 거칩니다.25
function 이스케이프(원문: string) -> string {26
원문.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)27
}29
function 숫자인가(글자: string) -> bool {30
let 코드 = 글자.codePointAt(0) ?? 031
코드 >= 48 && 코드 <= 5732
}34
// 성공 값과 안내가 모두 바로 보여 줄 HTML 조각일 때 진입점에서 한 번 풉니다. 토파즈 과제 'Result 오류 쪽 변환과 풀기' 의 우회입니다.35
function 결과풀기(값: Result<string, string>) -> string {36
match 값 {37
case Ok(결과) => 결과38
case Err(안내) => 안내39
}40
}42
// 공용 도우미 끝44
function 토큰오류(번호: int, 토큰: string, 이유: string) -> string {45
"<div class=\"err\">{번호}번째 토큰 '{이스케이프(토큰)}': {이유}</div>"46
}48
// 검사 순서가 곧 여러 문제가 겹친 토큰에서 보여 줄 오류의 순서입니다.49
function 토큰읽기(토큰: string, 번호: int) -> Result<음표, string> {50
let 부분 = 토큰.split("/")51
if 부분.length != 2 { return Err(토큰오류(번호, 토큰, "음이름/박자 형식이어야 합니다")) }52
let 박자 = match 부분[1] {53
case "1" => 154
case "2" => 255
case "4" => 456
case "8" => 857
case "16" => 1658
case _ => return Err(토큰오류(번호, 토큰, "박자는 1, 2, 4, 8, 16 중 하나여야 합니다"))59
}60
if 부분[0] == "R" { return Ok(음표 { 높이: 음높이.쉼, 박자: 박자 }) }61
let 글자들 = 부분[0].scalars()62
if 글자들.length < 2 || 글자들.length > 3 { return Err(토큰오류(번호, 토큰, "잘못된 음이름입니다")) }63
let 색인 = match 글자들[0] {64
case "C" => 065
case "D" => 166
case "E" => 267
case "F" => 368
case "G" => 469
case "A" => 570
case "B" => 671
case _ => return Err(토큰오류(번호, 토큰, "음이름은 A~G여야 합니다"))72
}73
let 변화 = if 글자들.length == 2 {74
변화표.제자리75
} else {76
match 글자들[1] {77
case "#" => 변화표.올림표78
case "b" => 변화표.내림표79
case _ => return Err(토큰오류(번호, 토큰, "변화표는 # 또는 b여야 합니다"))80
}81
}82
let 옥타브글 = 글자들[글자들.length - 1]83
if !숫자인가(옥타브글) { return Err(토큰오류(번호, 토큰, "옥타브는 한 자리 숫자여야 합니다")) }84
Ok(음표 { 높이: 음높이.소리((toInt(옥타브글) ?? 0) * 7 + 색인, 변화), 박자: 박자 })85
}87
// 탭과 줄바꿈은 토큰을 나누지 않습니다.88
function 음표읽기(입력: string) -> Result<Array<음표>, string> {89
let 토큰들 = 입력.trim().split(" ").filter(토큰 => 토큰 != "")90
let mut 음표들: Array<음표> = []91
for i in 0..<토큰들.length { 음표들.push(토큰읽기(토큰들[i], i + 1)?) }92
Ok(음표들)93
}95
function 쉼표인가(음: 음표) -> bool {96
match 음.높이 {97
case 쉼 => true98
case 소리(_, _) => false99
}100
}102
function 음계위치(음: 음표) -> int {103
match 음.높이 {104
case 쉼 => 가운데줄105
case 소리(위치, _) => 위치106
}107
}109
function 들보가능인가(음: 음표) -> bool {110
match 음.높이 {111
case 쉼 => false112
case 소리(_, _) => 음.박자 in [8, 16]113
}114
}116
// 맨 아랫줄 E4 가 음계위치 30, y 88 이고 한 단계 오를 때마다 6 씩 올라갑니다.117
function 세로계산(위치: int) -> float {118
88.0 - toFloat(위치 - 30) * 6.0119
}121
// 온음표 4박부터 16분음표 0.25박까지 박의 길이에 비례해 띄웁니다.122
function 너비(박자: int) -> float {123
30.0 + 14.0 * (4.0 / toFloat(박자))124
}126
// 왼쪽부터 차례로 더해야 소수 출력이 같습니다. 마지막 값은 끝 음표의 다음 자리입니다.127
function 가로누적(음표들: Array<음표>) -> Array<float> {128
let mut 가로들 = [56.0]129
for 음 in 음표들 { 가로들.push(가로들[가로들.length - 1] + 너비(음.박자)) }130
가로들131
}133
function 선(x1: float, y1: float, x2: float, y2: float, 두께: string) -> string {134
"<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" stroke=\"#111\" stroke-width=\"{두께}\" />"135
}137
function 머리(x: float, y: float, 채움: bool) -> string {138
let 색 = if 채움 { "#111" } else { "#fff" }139
"<ellipse cx=\"{x}\" cy=\"{y}\" rx=\"5\" ry=\"4\" fill=\"{색}\" stroke=\"#111\" stroke-width=\"1.5\" />"140
}142
// 내용은 고정 기호뿐이라 이스케이프하지 않습니다.143
function 기호(x: float, y: float, 크기: string, 내용: string) -> string {144
"<text x=\"{x}\" y=\"{y}\" font-size=\"{크기}\" text-anchor=\"middle\" fill=\"#111\">{내용}</text>"145
}147
// 온쉼표부터 16분쉼표까지 유니코드 음악 기호 U+1D13B ~ U+1D13F 입니다. 16진 리터럴이 없어 10진으로 씁니다. 토파즈 과제 '16진 정수 리터럴' 의 우회입니다.148
function 쉼표기호(박자: int) -> string {149
let 코드 = match 박자 {150
case 1 => 119611151
case 2 => 119612152
case 4 => 119613153
case 8 => 119614154
case _ => 119615155
}156
fromCodePoint(코드) ?? ""157
}159
// 오선 밖 머리에서 오선 쪽으로 줄 자리마다 보조선을 긋습니다. 맨 윗줄 F5 는 38, 맨 아랫줄 E4 는 30 입니다.160
function 보조선(위치: int, x: float) -> string {161
let 줄자리들 = if 위치 >= 40 {162
[for 줄 in 40..<(위치 + 1) if 줄 % 2 == 0 => 줄]163
} else if 위치 <= 28 {164
[for 거리 in 0..<(29 - 위치) if 거리 % 2 == 0 => 28 - 거리]165
} else {166
[]167
}168
줄자리들.map(줄 => 선(x - 8.0, 세로계산(줄), x + 8.0, 세로계산(줄), "1")).join("")169
}171
function 기둥끝(y: float, 위로: bool) -> float {172
if 위로 { y - 기둥길이 } else { y + 기둥길이 }173
}175
// 들보로 묶이지 않은 8분음표는 깃발 하나, 16분음표는 둘입니다.176
function 깃발(x: float, y: float, 박자: int, 위로: bool) -> string {177
let 끝 = 기둥끝(y, 위로)178
if 위로 {179
let 기둥x = x + 5.0180
let 둘째 = if 박자 == 16 { 선(기둥x, 끝 + 5.0, 기둥x + 7.0, 끝 + 15.0, "2") } else { "" }181
선(기둥x, 끝, 기둥x + 7.0, 끝 + 10.0, "2") + 둘째182
} else {183
let 기둥x = x - 5.0184
let 둘째 = if 박자 == 16 { 선(기둥x, 끝 - 5.0, 기둥x + 7.0, 끝 - 15.0, "2") } else { "" }185
선(기둥x, 끝, 기둥x + 7.0, 끝 - 10.0, "2") + 둘째186
}187
}189
function 홀로기둥(x: float, y: float, 박자: int, 위로: bool) -> string {190
let 기둥x = if 위로 { x + 5.0 } else { x - 5.0 }191
let 기둥 = 선(기둥x, y, 기둥x, 기둥끝(y, 위로), "1.5")192
if 박자 in [8, 16] { 기둥 + 깃발(x, y, 박자, 위로) } else { 기둥 }193
}195
// 온음표는 기둥이 없고, 들보로 묶인 음표의 기둥은 들보와 함께 그립니다.196
function 음표그림(놓인: 놓인음표, 묶임: bool) -> string {197
let x = 놓인.가로198
let y = 놓인.세로199
let 박자 = 놓인.음.박자200
match 놓인.음.높이 {201
case 쉼 => 기호(x, 70.0, "30", 쉼표기호(박자))202
case 소리(위치, 변화) => {203
// 올림표는 U+266F, 내림표는 U+266D 입니다.204
let 변화기호 = match 변화 {205
case 제자리 => ""206
case 올림표 => 기호(x - 11.0, y + 4.0, "18", fromCodePoint(9839) ?? "")207
case 내림표 => 기호(x - 11.0, y + 4.0, "18", fromCodePoint(9837) ?? "")208
}209
let 기둥 = if 박자 == 1 || 묶임 { "" } else { 홀로기둥(x, y, 박자, 위치 < 가운데줄) }210
[보조선(위치, x), 변화기호, 머리(x, y, 박자 >= 4), 기둥].join("")211
}212
}213
}215
function 구간끝(표시: Array<bool>, 시작: int, 한계: int) -> int {216
let mut 끝 = 시작217
while 끝 < 한계 && 표시[끝 + 1] { 끝 += 1 }218
끝219
}221
// 시작부터 끝까지에서 표시가 참으로 이어진 구간들입니다.222
function 구간들(표시: Array<bool>, 시작: int, 끝: int) -> Array<구간> {223
[for i in 시작..<(끝 + 1) if 표시[i] && (i == 시작 || !표시[i - 1]) => 구간 { 시작: i, 끝: 구간끝(표시, i, 끝) }]224
}226
function 들보높이(들보: 들보선, x: float) -> float {227
들보.y + 들보.기울기 * (x - 들보.x)228
}230
// 16분음표가 이어지면 덧들보로 잇고, 하나뿐이면 묶음 안쪽으로 짧게 냅니다.231
function 덧들보(기둥x들: Array<float>, 연속: 구간, 들보: 들보선, 간격: float) -> string {232
let x = 기둥x들[연속.시작]233
let y = 들보높이(들보, x) + 간격234
if 연속.끝 > 연속.시작 {235
let 끝x = 기둥x들[연속.끝]236
선(x, y, 끝x, 들보높이(들보, 끝x) + 간격, "3")237
} else if 연속.시작 < 기둥x들.length - 1 {238
선(x, y, x + 8.0, y + 들보.기울기 * 8.0, "3")239
} else {240
선(x - 8.0, y - 들보.기울기 * 8.0, x, y, "3")241
}242
}244
// 평균 음계위치가 가운데 줄보다 낮을 때만 기둥을 올립니다. 들보는 첫 기둥 끝과 마지막 기둥 끝을 잇는 직선입니다.245
function 들보그림(묶음: Array<놓인음표>) -> string {246
let 위로 = 묶음.reduce(0, (합, 놓인) => 합 + 음계위치(놓인.음)) < 가운데줄 * 묶음.length247
let 기둥쪽 = if 위로 { 5.0 } else { -5.0 }248
let 덧간격 = if 위로 { 5.0 } else { -5.0 }249
let 기둥x들 = 묶음.map(놓인 => 놓인.가로 + 기둥쪽)250
let 마지막 = 묶음.length - 1251
let 첫y = 기둥끝(묶음[0].세로, 위로)252
let 끝y = 기둥끝(묶음[마지막].세로, 위로)253
let 들보 = 들보선 { x: 기둥x들[0], y: 첫y, 기울기: (끝y - 첫y) / (기둥x들[마지막] - 기둥x들[0]) }254
let 기둥들 = map(0..<묶음.length, i => 선(기둥x들[i], 묶음[i].세로, 기둥x들[i], 들보높이(들보, 기둥x들[i]), "1.5"))255
let 십육분 = 묶음.map(놓인 => 놓인.음.박자 == 16)256
let 덧들보들 = 구간들(십육분, 0, 마지막).map(연속 => 덧들보(기둥x들, 연속, 들보, 덧간격))257
기둥들.join("") + 선(기둥x들[0], 첫y, 기둥x들[마지막], 끝y, "4") + 덧들보들.join("")258
}260
function 그리기(음표들: Array<음표>) -> string {261
let n = 음표들.length262
let 가로들 = 가로누적(음표들)263
let 놓인들 = map(0..<n, i => 놓인음표 { 음: 음표들[i], 가로: 가로들[i], 세로: 세로계산(음계위치(음표들[i])) })264
let 전체너비 = 가로들[n] + 24.0265
let 가능 = 음표들.map(들보가능인가)266
let 묶임 = [for i in 0..<n => 가능[i] && ((i > 0 && 가능[i - 1]) || (i + 1 < n && 가능[i + 1]))]267
let 묶음들 = 구간들(가능, 0, n - 1).filter(묶음 => 묶음.끝 > 묶음.시작)268
// 자리표는 기준선 86, 글자 크기 48 로 오선 위아래로 뻗으므로 보이는 범위를 그 폭에서 시작합니다.269
let 머리세로들 = [for 놓인 in 놓인들 if !쉼표인가(놓인.음) => 놓인.세로]270
let 위쪽 = 머리세로들.reduce(28.0, (위, y) => if y - 42.0 < 위 { y - 42.0 } else { 위 })271
let 아래쪽 = 머리세로들.reduce(108.0, (아래, y) => if y + 42.0 > 아래 { y + 42.0 } else { 아래 })272
let 보기위 = 위쪽 - 5.0273
let 보기높이 = (아래쪽 - 위쪽) + 10.0274
// 높은음자리표는 U+1D11E 입니다.275
let 오선 = [38, 36, 34, 32, 30].map(세로계산).map(y => 선(10.0, y, 전체너비 - 10.0, y, "1"))276
[277
"<svg viewBox=\"0 {보기위} {전체너비} {보기높이}\" width=\"{전체너비}\" height=\"{보기높이}\" xmlns=\"http://www.w3.org/2000/svg\" font-family=\"serif\">",278
오선.join(""),279
기호(30.0, 86.0, "48", fromCodePoint(119070) ?? ""),280
map(0..<n, i => 음표그림(놓인들[i], 묶임[i])).join(""),281
묶음들.map(묶음 => 들보그림(놓인들.slice(묶음.시작, 묶음.끝 + 1))).join(""),282
"</svg>",283
].join("")284
}286
function 기보(입력: string) -> Result<string, string> {287
let 음표들 = 음표읽기(입력)?288
if 음표들.length == 0 { return Err(빈입력안내) }289
Ok("<div class=\"out\">" + 그리기(음표들) + "</div>")290
}292
input() |> 기보 |> 결과풀기 |> printSHA256 685a3188565160b7e0e4c0fa668d2920abe76179475293c32d9e4a88384e27dc