진행하는 프로젝트에서 악보를 띄워야 하는 일이 있는데, 생각보다 악보를 그리는 일이 쉽지가 않았다. 악보를 다루는 포맷은 여러가지가 있는데, 나는 MusicXML을 입력으로 받아서 악보 이미지를 띄워야 했다. pub.dev를 찾아본 결과 Flutter 패키지 중에는 따로 악보 및 MusicXML을 다루는 패키지가 없었기 때문에, 해결방법은...
정도가 있을 듯 했다.
방법1은 정말 만만치가 않다. 생각보다 악보를 구성하는 요소들이 많기 때문에 이걸 다 구현하면 프로젝트 내내 이것만 하게 될 것 같았다. 그럼에도 직접 시도하려는 사람이 있다면 ghost23의 데모 앱을 참고해보길 바란다.
만약 방법2로 진행한다면, python의 music21 라이브러리를 사용하면 비교적 간단하게 구현이 가능하다. 하지만 문제는 이 악보를 프롬프팅해야 한다는 것이다. 즉, 곡의 진행에 맞추어 악보를 넘겨주고, 음표에 하이라이트를 쳐서 표시를 해야 되므로 각 음표 별 위치를 파악하는 작업이 필요하다. 결론부터 말하자면, music21로 이게 가능한지는 찾아보지 못했다. 제 3의 방법을 찾았기 때문이다.
OpenSheetMusicDisplay renders MusicXML sheet music in the browser.
데모 예시 사진에 커서가 있다는 점이 우선 감격적이다. 소개 문구를 보면 browser라는 단어를 제외하면 완벽하다.
브라우저라는 설명에서 짐작할 수 있지만, 해당 라이브러리는 JS(TS)로 작성되었기 때문에 flutter와 상성이 그렇게 좋다고는 할 수 없다. 이쯤에서 아 차라리 React Native를 썼어야 했나 하는 후회가 살짝 들었으나이미늦었어, 뒤에 설명하겠지만 해결할 방법이 없는 것도 아니고, 개인적으로 UI를 구현하는데 React보다 Flutter가 훨씬 편했던 점을 고려하여 유턴은 하지 않기로 했다. 그렇다면 Flutter에서 어떻게 JS를 실행할까.
해결책은 webview를 띄우는 것이다. 웹뷰란 앱 내에 임베딩 된 브라우저이다. 카카오톡에서 웹페이지 링크를 눌렀을 때 뜨는 새 창 같은 것을 생각해볼 수 있겠다. 웹페이지 로딩에는 JS를 실행하는 브라우저가 필요하다. 보이기에는 그냥 새로운 창이 뜬 것 같지만, 사실은 간이 브라우저(=웹뷰)라고 생각할 수 있겠다. (웹 개발자의 적...)
Flutter에서 webview 패키지는 굉장히 여러가지가 있는데, 나의 요구조건은 다음과 같았다.
1.에 대해 보충을 해보자면, OSMD는 그저 라이브러리일 뿐이므로 나는 실제 호스팅 되고 있는 사이트를 렌더링하는 것이 아니다. 웹뷰에 많이 나오는 예제인 유튜브 동영상 렌더링과는 다르다. 실제로 html과 js를 모바일 기기에서 localhost로 띄워서 접근해야 한다.
결론적으로, 공식 문서가 자세하고 점수가 높으며 활발하게 업데이트 되고 있는 flutter_inappwebview를 선택했다.
이 부분은 OSMD 측에서 제공한 raw javascript usage example을 참고하여 작성했다.
정확히 해당 레포에서 index.html과 fileSelectAndLoadOSMD.js 두가지 파일을 받아서 시작했다. 추가로 OSMD 배포 파일인 opensheetmusicdisplay.min.js 를 받아주면 준비는 끝난다.
추가로 테스트용으로 사용할 MusicXML파일도 받아주자.
3개의 파일을 assets 폴더에 넣어주면 된다.
개인적으로 나는 assets/web 폴더에 js와 html을 구분해서 넣었지만, 경로만 맞춰주면 어디든 무관하다.
index.html에서 script 태그를 사용할 때는 상대 경로로 맞춰주면 된다.
<script src="./js/opensheetmusicdisplay.min.js"></script>
<script src="./js/fileSelectAndLoadOSMD.js"></script>
flutter에서 이렇게 추가한 asset을 쓰고 싶으면 pubspec.yaml
파일에서 asset의 경로를 추가해주어야 한다.
처음에 폴더를 통째로 넣고 싶어서
assets/web/
assets/web/*
등등 시도해 보았으나 이렇게 하면 파일을 못 찾는거 같아서 무식하지만 그냥 하나씩 써주었다.
이제 준비는 끝났다.
코드를 부분적으로 설명해보자면,
index.html이 있는 루트 폴더를 지정해주면 localhost를 실행해준다.
포트를 지정하지 않는다면, 기본적으로 8080으로 배정된다.
InAppLocalhostServer localhostServer =
InAppLocalhostServer(documentRoot: 'assets/web');
이후는 나름 간단하다. url에 localhost:8080을 주면 asset의 index.html이 렌더링된다.
InAppWebView(
initialUrlRequest = URLRequest(
url: WebUri('http://localhost:8080'),
),
);
파일 입력의 경우, Flutter에서 열어서 webview에 byte 문자열로 넘겨주는 방식을 사용하였다.
asset을 사용하는 경우 rootBundle을 사용해서 간단히 읽어오면 된다.
String fileString = file = await rootBundle.loadString('assets/music/demo.xml');
만약 기기에서 파일을 입력 받는다면, File 객체를 선언해서 경로를 사용해 읽어오면 된다. 이 경우 따로 디코딩을 해주어야 하는데, 파일 인코딩 형식에 따라 다르겠지만 일반적으로는 utf8로 디코드 하면 잘 읽어질 것이다.
file = File(filePath);
utf8.decode(await file.readAsBytes());
이제 이 byte string을 웹뷰 쪽에 넘겨야 한다. webview와의 통신은 항상 webview측에서 시작한다고 생각하면 된다. 따라서 handler를 등록하고, 웹뷰의 자바스크립트 파일에서 해당 handler를 호출한다.
InAppWebView(
...,
onWebViewCreated = (controller) async { // 웹뷰 생성되었을 때 호출되는 부분
controller.addJavaScriptHandler( // handler 등록
handlerName: 'sendFileToOSMD', // javascript에서 호출할 때 사용되는 handler 이름
callback: (args) async {
return {
'bytes': fileString, // asset에서 불러오는 경우
//'bytes': utf8.decode(await file.readAsBytes()),// File 객체 사용하는 경우
'name': file.path
};
});
},
);
위와 같이 'sendFileToOSMD'라는 이름의 handler를 등록하였다.
2-1. index.html
기존 파일에는 파일을 올릴 수 있는 file input 태그가 있었지만, 이제는 flutter에서 파일을 읽어서 넘기기 때문에 필요가 없다.
따라서 전부 지우고, osmdCanvas를 id로 갖는 div 태그만 남기면 된다.
<html>
<script src="./js/opensheetmusicdisplay.min.js"></script>
<script src="./js/fileSelectAndLoadOSMD.js"></script>
<body>
<div id="osmdCanvas" style="width: 1024px" />
</body>
</html>
2-2. fileSelectAndLoadOSMD.js
기존 코드는 input 태그를 통해 파일을 입력받고 해당 이벤트를 처리하는 방식으로 되어 있으므로 현재와 방식이 다르기 때문에 코드 수정이 필요하다.
우선, handler를 호출해서 파일 byte string을 받아와야 한다.
window.flutter_inappwebview.callHandler("sendFileToOSMD");
이 때, script 상단에서 handler를 호출하면 아직 flutter_inappwebview가 생기기 전이므로 에러가 발생할 수 있다. 따라서, 이 부분이 제대로 설정될 때까지 기다려주어야 한다. inappwebview는 준비가 다 된 경우 flutterInAppWebViewPlatformReady
이벤트를 발생시키므로, 해당 이벤트 발생시 handler를 호출하도록 이벤트 리스너를 추가해준다.
window.addEventListener("flutterInAppWebViewPlatformReady", async function (_) {
// handler 호출
const inputJson = await window.flutter_inappwebview.callHandler("sendFileToOSMD");
// osmd 시작하기
startOSMD(inputJson.bytes);
});
이제 거의 다 되었다! startOSMD 함수만 만들어주면 된다.
기존 코드에서는 파일을 읽고, 파일을 다 읽으면 osmd를 호출하는 방식인데 우리는 이미 파일을 읽은 byte string을 가지고 있으므로 그럴 필요가 없다.
참고로 OSMD의 다양한 옵션은 OSMDOptions.ts에서 볼 수 있다.
async function startOSMD(fileData) {
var osmd = new opensheetmusicdisplay.OpenSheetMusicDisplay("osmdCanvas", {
backend: "canvas",
resize: true,
drawFromMeasureNumber: 1,
drawUpToMeasureNumber: Number.MAX_SAFE_INTEGER,
drawTitle: false, // 제목 그릴지 여부
drawPartNames: false, // 악보의 각 파트를 그릴지
});
await osmd.load(fileData, "");
window.osmd = osmd;
await osmd.render();
return osmd;
}
끝!
아래는 전체 코드이다.
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
void main() {
runApp(const OSMDScreen());
}
class OSMDScreen extends StatefulWidget {
const OSMDScreen({
super.key,
});
State<OSMDScreen> createState() => _OSMDScreenState();
}
class _OSMDScreenState extends State<OSMDScreen> {
InAppLocalhostServer localhostServer =
InAppLocalhostServer(documentRoot: 'assets/web');
late String fileString;
bool isLoading = true;
void initState() {
super.initState();
startLocalhost();
}
startLocalhost() async {
// 파일 로드
fileString = await rootBundle.loadString('assets/music/demo.xml');
// 로컬호스트 시작
await localhostServer.start();
setState(() {});
}
void dispose() {
// 위젯이 dispose되기 전에 localhost를 종료해야 한다.
localhostServer.close();
super.dispose();
}
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text("webview로 OSMD 사용하기 예제")),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (localhostServer.isRunning())
Expanded(
child: SizedBox.expand(
child: InAppWebView(
initialUrlRequest: URLRequest(
url: WebUri('http://localhost:8080'),
),
onWebViewCreated: (controller) async {
controller.addJavaScriptHandler(
handlerName: 'sendFileToOSMD',
callback: (args) async {
return {
'bytes': fileString,
};
});
},
),
),
)
],
),
),
);
}
}
index.html
<html>
<head>
<title>OSMD Raw Javascript Usage Example</title>
</head>
<style>
body {
padding: 0;
margin: 0 auto;
align-self: center;
display: flex;
justify-content: center;
}
</style>
<script src="./js/opensheetmusicdisplay.min.js"></script>
<script src="./js/fileSelectAndLoadOSMD.js"></script>
<body>
<div id="osmdCanvas" style="width: 1024px" />
</body>
</html>
fileSelectAndLoadOSMD.js
// 준비가 끝나면
window.addEventListener("flutterInAppWebViewPlatformReady", async function (_) {
const inputJson = await window.flutter_inappwebview.callHandler( // sendFileToOSMD 호출
"sendFileToOSMD"
);
startOSMD(inputJson.bytes);
});
/*
musicXML byte string을 읽어서 OSMD 시스템에 넘기는 함수
*/
async function startOSMD(fileData) {
var osmd = new opensheetmusicdisplay.OpenSheetMusicDisplay("osmdCanvas", {
backend: "canvas",
resize: true,
drawFromMeasureNumber: 1,
drawUpToMeasureNumber: Number.MAX_SAFE_INTEGER,
drawTitle: false, // 제목 그릴지 여부
drawPartNames: false, // 악보의 각 파트를 그릴지
});
await osmd.load(fileData, "");
window.osmd = osmd;
// 이미지 렌더링
await osmd.render();
return osmd;
}
MusicXML
OSMD - github
OSMD - raw javascript usage example
OSMD Options
flutter_inappwebview