slow.monster

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.
8enum 변화표 { 제자리, 올림표, 내림표 }
9// 음계위치는 옥타브 * 7 + 음이름 색인(C 0 ~ B 6)이라 1 이 오선의 줄과 칸 한 단계입니다.
10enum 음높이 { 쉼, 소리(int, 변화표) }
11record 음표 { 높이: 음높이, 박자: int }
12record 놓인음표 { 음: 음표, 가로: float, 세로: float }
13record 구간 { 시작: int, 끝: int }
14record 들보선 { x: float, y: float, 기울기: float }
16let 기둥길이 = 30.0
17// 가운데 줄 B4 입니다. 기둥 방향을 가르는 기준이고 쉼표도 이 자리에 둡니다.
18let 가운데줄 = 34
19let 빈입력안내 = "<div class=\"meta\">가락을 입력하세요. 예: C4/4 D4/4 E4/8 E4/8 G4/2</div>"
21// 공용 도우미 시작 (이스케이프 숫자인가 결과풀기)
22// 여러 도구가 같은 원본을 씁니다. 여기서 고치지 않고 원본에서 고친 뒤 다시 생성합니다.
24// 사용자 입력을 HTML 에 넣기 전에 반드시 거칩니다.
25function 이스케이프(원문: string) -> string {
26 원문.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
29function 숫자인가(글자: string) -> bool {
30 let 코드 = 글자.codePointAt(0) ?? 0
31 코드 >= 48 && 코드 <= 57
34// 성공 값과 안내가 모두 바로 보여 줄 HTML 조각일 때 진입점에서 한 번 풉니다. 토파즈 과제 'Result 오류 쪽 변환과 풀기' 의 우회입니다.
35function 결과풀기(값: Result<string, string>) -> string {
36 match 값 {
37 case Ok(결과) => 결과
38 case Err(안내) => 안내
39 }
42// 공용 도우미 끝
44function 토큰오류(번호: int, 토큰: string, 이유: string) -> string {
45 "<div class=\"err\">{번호}번째 토큰 '{이스케이프(토큰)}': {이유}</div>"
48// 검사 순서가 곧 여러 문제가 겹친 토큰에서 보여 줄 오류의 순서입니다.
49function 토큰읽기(토큰: string, 번호: int) -> Result<음표, string> {
50 let 부분 = 토큰.split("/")
51 if 부분.length != 2 { return Err(토큰오류(번호, 토큰, "음이름/박자 형식이어야 합니다")) }
52 let 박자 = match 부분[1] {
53 case "1" => 1
54 case "2" => 2
55 case "4" => 4
56 case "8" => 8
57 case "16" => 16
58 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" => 0
65 case "D" => 1
66 case "E" => 2
67 case "F" => 3
68 case "G" => 4
69 case "A" => 5
70 case "B" => 6
71 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 + 색인, 변화), 박자: 박자 })
87// 탭과 줄바꿈은 토큰을 나누지 않습니다.
88function 음표읽기(입력: 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(음표들)
95function 쉼표인가(음: 음표) -> bool {
96 match 음.높이 {
97 case 쉼 => true
98 case 소리(_, _) => false
99 }
102function 음계위치(음: 음표) -> int {
103 match 음.높이 {
104 case 쉼 => 가운데줄
105 case 소리(위치, _) => 위치
106 }
109function 들보가능인가(음: 음표) -> bool {
110 match 음.높이 {
111 case 쉼 => false
112 case 소리(_, _) => 음.박자 in [8, 16]
113 }
116// 맨 아랫줄 E4 가 음계위치 30, y 88 이고 한 단계 오를 때마다 6 씩 올라갑니다.
117function 세로계산(위치: int) -> float {
118 88.0 - toFloat(위치 - 30) * 6.0
121// 온음표 4박부터 16분음표 0.25박까지 박의 길이에 비례해 띄웁니다.
122function 너비(박자: int) -> float {
123 30.0 + 14.0 * (4.0 / toFloat(박자))
126// 왼쪽부터 차례로 더해야 소수 출력이 같습니다. 마지막 값은 끝 음표의 다음 자리입니다.
127function 가로누적(음표들: Array<음표>) -> Array<float> {
128 let mut 가로들 = [56.0]
129 for 음 in 음표들 { 가로들.push(가로들[가로들.length - 1] + 너비(음.박자)) }
130 가로들
133function 선(x1: float, y1: float, x2: float, y2: float, 두께: string) -> string {
134 "<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" stroke=\"#111\" stroke-width=\"{두께}\" />"
137function 머리(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\" />"
142// 내용은 고정 기호뿐이라 이스케이프하지 않습니다.
143function 기호(x: float, y: float, 크기: string, 내용: string) -> string {
144 "<text x=\"{x}\" y=\"{y}\" font-size=\"{크기}\" text-anchor=\"middle\" fill=\"#111\">{내용}</text>"
147// 온쉼표부터 16분쉼표까지 유니코드 음악 기호 U+1D13B ~ U+1D13F 입니다. 16진 리터럴이 없어 10진으로 씁니다. 토파즈 과제 '16진 정수 리터럴' 의 우회입니다.
148function 쉼표기호(박자: int) -> string {
149 let 코드 = match 박자 {
150 case 1 => 119611
151 case 2 => 119612
152 case 4 => 119613
153 case 8 => 119614
154 case _ => 119615
155 }
156 fromCodePoint(코드) ?? ""
159// 오선 밖 머리에서 오선 쪽으로 줄 자리마다 보조선을 긋습니다. 맨 윗줄 F5 는 38, 맨 아랫줄 E4 는 30 입니다.
160function 보조선(위치: 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("")
171function 기둥끝(y: float, 위로: bool) -> float {
172 if 위로 { y - 기둥길이 } else { y + 기둥길이 }
175// 들보로 묶이지 않은 8분음표는 깃발 하나, 16분음표는 둘입니다.
176function 깃발(x: float, y: float, 박자: int, 위로: bool) -> string {
177 let 끝 = 기둥끝(y, 위로)
178 if 위로 {
179 let 기둥x = x + 5.0
180 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.0
184 let 둘째 = if 박자 == 16 { 선(기둥x, 끝 - 5.0, 기둥x + 7.0, 끝 - 15.0, "2") } else { "" }
185 선(기둥x, 끝, 기둥x + 7.0, 끝 - 10.0, "2") + 둘째
186 }
189function 홀로기둥(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 { 기둥 }
195// 온음표는 기둥이 없고, 들보로 묶인 음표의 기둥은 들보와 함께 그립니다.
196function 음표그림(놓인: 놓인음표, 묶임: 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 }
215function 구간끝(표시: Array<bool>, 시작: int, 한계: int) -> int {
216 let mut 끝 = 시작
217 while 끝 < 한계 && 표시[끝 + 1] { 끝 += 1 }
218 끝
221// 시작부터 끝까지에서 표시가 참으로 이어진 구간들입니다.
222function 구간들(표시: Array<bool>, 시작: int, 끝: int) -> Array<구간> {
223 [for i in 시작..<(끝 + 1) if 표시[i] && (i == 시작 || !표시[i - 1]) => 구간 { 시작: i, 끝: 구간끝(표시, i, 끝) }]
226function 들보높이(들보: 들보선, x: float) -> float {
227 들보.y + 들보.기울기 * (x - 들보.x)
230// 16분음표가 이어지면 덧들보로 잇고, 하나뿐이면 묶음 안쪽으로 짧게 냅니다.
231function 덧들보(기둥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 }
244// 평균 음계위치가 가운데 줄보다 낮을 때만 기둥을 올립니다. 들보는 첫 기둥 끝과 마지막 기둥 끝을 잇는 직선입니다.
245function 들보그림(묶음: Array<놓인음표>) -> string {
246 let 위로 = 묶음.reduce(0, (합, 놓인) => 합 + 음계위치(놓인.음)) < 가운데줄 * 묶음.length
247 let 기둥쪽 = if 위로 { 5.0 } else { -5.0 }
248 let 덧간격 = if 위로 { 5.0 } else { -5.0 }
249 let 기둥x들 = 묶음.map(놓인 => 놓인.가로 + 기둥쪽)
250 let 마지막 = 묶음.length - 1
251 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("")
260function 그리기(음표들: Array<음표>) -> string {
261 let n = 음표들.length
262 let 가로들 = 가로누적(음표들)
263 let 놓인들 = map(0..<n, i => 놓인음표 { 음: 음표들[i], 가로: 가로들[i], 세로: 세로계산(음계위치(음표들[i])) })
264 let 전체너비 = 가로들[n] + 24.0
265 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.0
273 let 보기높이 = (아래쪽 - 위쪽) + 10.0
274 // 높은음자리표는 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("")
286function 기보(입력: string) -> Result<string, string> {
287 let 음표들 = 음표읽기(입력)?
288 if 음표들.length == 0 { return Err(빈입력안내) }
289 Ok("<div class=\"out\">" + 그리기(음표들) + "</div>")
292input() |> 기보 |> 결과풀기 |> print

SHA256 685a3188565160b7e0e4c0fa668d2920abe76179475293c32d9e4a88384e27dc