目次
[1]. はじめに
[2]. VOICEVOXとは
[3]. 環境構築と実行
[4]. 扱ったIssues
[5]. 感想
[1]. はじめに
この記事は東京大学工学部電子情報工学科・電気電子工学科の後期実験「大規模ソフトウェアを手探る」のレポートです。
私たちはVOICEVOXというOSSの機能拡張やバグ修正を行いました。
「大規模ソフトウェアを手探る」は、自分で一からプログラムを書いてソフトウェアを作るのではなく、膨大な量のソースコードからなるソフトウェアを、デバッガ等を駆使しながら必要な部分だけ読み解き、改良を加えるという一風変わったプログラミングの実験です。
[2]. VOICEVOXとは
VOICEVOXはディープラーニングを使ったテキスト読み上げソフトウェアです。製品版VOICEVOXのずんだもんはYouTubeで見たことがある人も多いと思います(個人的には水道事業の動画が好きです)。
製品版VOICEVOXでは、たくさんの個性豊かなキャラクターがテキストを読み上げてくれます。また、アクセントやイントネーションを直感的に変更することができるので、音声合成の知識がなくても手軽に自然な音声を生成することができます。
VOICEVOXはエディター、エンジン、コアからなるソフトウェアで、今回変更を加えたのはエディター部分です。
出典: VOICEVOX 全体構成.md
以下の理由で、VOICEVOXエディターを実験の題材として選びました。
- ずんだもんの動画などで、親しみがある
- Issuesに「初心者歓迎タスク」や「バグ」などのタグが付けられており、数も多く、取り掛かるIssueの見当がつけやすい
- ドキュメントやIssues等が全て日本語で書かれているため、DeveloperやContributorsとの議論を齟齬なくスムーズに行える
- UIを担当するエディターは変更点を把握しやすい
- 初心者にとってフロントが最も敷居が低い
[3]. 環境構築と実行
ソフトウェアを開発するに当たってまず必要なのは環境構築ですね。
READMEを読みながら環境構築とVOICEVOXの実行までやっていきましょう。
VOICEVOXはREADMEに限らず開発者向けのドキュメントが充実していて、OSS開発の敷居がとても低くなっています!
ちなみに班員の開発環境はmacOS Ventura 13.4.1です。
(1). 製品版VOICEVOXのダウンロード
[2]. VOICEVOXとは で述べたようにVOICEVOXが全体として機能するにはエディターだけでなくエンジンとコアが必要になります。
VOICEVOX 公式サイトから製品版VOICEVOXをダウンロードすれば簡単にエンジンとコアを利用できます。
(2). Node.jsのインストール
VoltaというNode.jsの管理ツールを使うとスムーズにインストールできます。
Unix系OSおよびWSLを使っている場合は以下のコマンドでVoltaを導入できます。
curl https://get.volta.sh | bash
一旦、Voltaがデフォルトで使用するNode.jsを入れます。node@14.15.5などでバージョンを指定することができますが、指定しない場合はLTS版がインストールされます。
volta install node
volta install npm
(3). VOICEVOXのソースコードをダウンロード
VOICEVOXのリポジトリをCloneするなりZIPをダウンロードするなりして、ソースコードを手に入れます。PRを送ろうと考えているならForkしてからCloneしましょう。
git clone git@github.com:VOICEVOX/voicevox.git
プロジェクトのディレクトリに移動するとVoltaがpackage.jsonに記載されているバージョンのnodeとnpmに自動的に切り替えてれくれます。
cd voicevox
(4). 依存関係のインストール
package-lock.jsonで指定されているパッケージをインストールします。
npm ci
(5). .envファイルの作成
.env.production
をコピーして .env
を作り、executionFilePath
に voicevox_engine
のパスを指定します。
今回は、製品版VOICEVOXのパスを指定しましょう。
"executionFilePath": "/Applications/VOICEVOX.app/Contents/MacOS/run",
VOICEVOXが使用しているビルドツールViteでは .env
で定義した環境変数がソースコード内で利用できるようになります。
(6). 実行
以下のコマンドで開発版のVOICEVOXが立ち上がります。ElectronはSlackやVisual Studio Codeなどで使われているデスクトップアプリケーションのフレームワークです。
npm run electron:serve
[4]. 扱ったIssues
今回の実験で我々が扱ったIssuesは以下の3つです。(以下は扱った順番です。)
[(1). 使い方のスクショ画像を入れ替える #853 (Merged)]
[(2). ショートカットキーを初期値に戻すと重複して設定出来てしまう #566 (Merged)]
[(3). アップデート可能かどうかを通知する #235 (Merged)]
(1). 使い方のスクショ画像を入れ替える #853 (Merged)
初めに扱うものとして、このIssueを選んだ理由として、以下のようなものが挙げられます。
- PRを出すことを前提にソースを見るのが初めてだったので、慣れておきたかった。
- Developerとの意思疎通の手段を確認しておきたかった。
- 1年以上扱われていなかった。
使い方のスクショ画像を入れ替える上で扱ったファイルがmdファイルであったので、ここではどのような形でPRを送ればいいかを述べようと思います。
まず、自分は公式のリポジトリをforkしました。
forkすると、自分のGitHubに公式と全く同じ内容を保持したリポジトリが自動で作成され、公式のソースに直接commit・pushすることなく、ソースの変更・調整を行えるようになります。公式のリポジトリに間違えてcommitしてしまった....といったことが起こらなくなるということです。
その後、forkしたリポジトリを自分のローカルにcloneし、変更・調整を適宜行った後は、自分のGitHubのリポジトリにpushしました。
そうすると、自身のリポジトリに変更内容が反映されるので、そこからPRを送りました。
以降はDeveloperと話して、適切に内容を変更していきます。
その際、新しくPRを送るのではなく、元々PRを行ったbranchに対し、変更を反映することで自動でGitHubがPR内容が更新してくれるので、そのようにしました。
このIssueを扱うことによって、forkする形でソースを変更していくことが、Issueを解決し、PRする際にスムーズであるという考えに至り、他のIssuesもそのようにしました。
(2). ショートカットキーを初期値に戻すと重複して設定出来てしまう #566 (Merged)
私はGUIでの操作よりもキーボード操作の方が好きなので、ショートカットキーを自分好みにカスタマイズすることが多いです。私のような利用者にとって、ショートカットキーが重複してしまうというのはクリティカルなバグであり、是非ともこのIssueを解決したいと思いました。
Issueの詳細です。現状ではショートカットキーの変更の仕方として、「ショートカットキーの設定」と「デフォルトに戻す」の2種類があります。「ショートカットキーの設定」では変更時に衝突チェックが行われ、被りがあれば別のキーを設定するよう促されます。しかし「デフォルトに戻す」では、衝突チェックが行われることなく初期のキー設定に(見かけ上)変更されます。本Issueのゴールは、初期値に戻す際にも衝突チェックを行うようにすることです。
衝突チェックの機構自体は既にあるので、あとはそれを「デフォルトに戻す」にも適用するだけ……かと思いきや、一筋縄ではいきませんでした。
現行の「ショートカットキーの設定」と「デフォルトに戻す」のソースコードを確認
ショートカットキーの設定画面に関するコードは components/HotkeySettingDialog.vue
に記述されています。
<q-card-section align="center">
<div class="text-h6">ショートカットキーを入力してください</div>
</q-card-section>
<q-card-section align="center">
<!-- 受け取ったキー入力を表示 -->
<template v-for="(hotkey, index) in lastRecord.split(' ')" :key="index">
<span v-if="index !== 0"> + </span>
<q-chip :ripple="false" color="surface">
{{ hotkey === "Meta" ? "Cmd" : hotkey }}
</q-chip>
</template>
<span v-if="lastRecord !== '' && confirmBtnEnabled"> +</span>
<!-- duplicatedHotkey で衝突チェック -->
<div v-if="duplicatedHotkey != undefined" class="text-warning q-mt-lg">
<div class="text-warning">
ショートカットキーが次の操作と重複しています
</div>
<div class="q-mt-sm text-weight-bold text-warning">
「{{ duplicatedHotkey.action }}」
</div>
</div>
</q-card-section>
「ショートカットキーの設定」では、まずショートカットキーをカスタムするダイアログ1が出現します。そして、ユーザーが入力したキーの組み合わせに対して、duplicatedHotkey
関数で衝突が無いか検証しています。
<q-btn
rounded
flat
icon="settings_backup_restore"
padding="none sm"
size="1em"
:disable="checkHotkeyReadonly(tableProps.row.action)"
@click="resetHotkey(tableProps.row.action)"
>
<q-tooltip :delay="500">デフォルトに戻す</q-tooltip>
<script setup lang="ts">
const resetHotkey = async (action: string) => {
const result = await store.dispatch("SHOW_CONFIRM_DIALOG", {
title: "ショートカットキーを初期値に戻します",
message: `${action}のショートカットキーを初期値に戻します。<br/>本当に戻しますか?`,
html: true,
actionName: "初期値に戻す",
cancel: "初期値に戻さない",
});
if (result === "OK") {
window.electron
.getDefaultHotkeySettings()
.then((defaultSettings: HotkeySetting[]) => {
// デフォルトの設定を取得
const setting = defaultSettings.find((value) => value.action == action);
if (setting) {
// action に setting.combination(ショートカットキー)を設定する
changeHotkeySettings(action, setting.combination);
}
});
}
};
</script>
一方「デフォルトに戻す」では、戻すボタンが押されると対象のアクションのデフォルトショートカットキーを取得し、何のチェックも行わず変更を行っています。changeHotkeySettings
を呼び出す前に衝突チェックの処理を追加すれば良さそうです。
duplicatedHotkey
関数の中身
duplicatedHotkey
ではどのような処理が行われているのでしょうか。
<script setup lang="ts">
const lastAction = ref(""); // 直近に選択されたアクション
const lastRecord = ref(""); // 直近に入力されたキーコンビネーション
const duplicatedHotkey = computed(() => {
if (lastRecord.value == "") return undefined;
// 入力されたショートカットキーが設定されているアクションが既に存在すれば、それを返す
return hotkeySettings.value.find(
(item) =>
item.combination == lastRecord.value && item.action != lastAction.value
);
});
</script>
computed()
という関数で動的に入力キーを受け取り、同じショートカットキーが設定されているアクションが既に存在すればそれを返す、という処理が行われていることがわかりました。
「デフォルトに戻す」に衝突チェックを導入
duplicatedHotkey
がキー入力にしか対応していないと思い込み、実装方針をしばらく悩みました。試行錯誤していたところ、duplicatedHotkey
は他の関数中で lastRecord
に代入されたコンビネーションに対しても判定を行ってくれることがわかりました。挙動を整理すると、次のようになります。
-
lastRecord
に何か値が入っている場合:その値に対して処理を行う -
lastRecord
に何も値が入っていない場合:キー入力に対して処理を行う
duplicatedHotkey
を resetHotkey
内で使えそうです!
「ショートカットキーの設定」とできるだけ挙動を合わせて……変更するコードは極力少なく……を目指した結果、変更後のコードはこのようになりました↓
<script>
const resetHotkey = async (action: string) => {
const result = await store.dispatch("SHOW_CONFIRM_DIALOG", {
title: "ショートカットキーを初期値に戻します",
message: `${action}のショートカットキーを初期値に戻します。<br/>本当に戻しますか?`,
html: true,
actionName: "初期値に戻す",
cancel: "初期値に戻さない",
});
if (result === "OK") {
window.electron
.getDefaultHotkeySettings()
.then((defaultSettings: HotkeySetting[]) => {
const setting = defaultSettings.find((value) => value.action == action);
// 変更部分ここから
if (setting === undefined) {
return;
}
// デフォルトが未設定でない場合は、衝突チェックを行う
if (setting.combination) {
const duplicated = hotkeySettings.value.find(
(item) =>
item.combination == setting.combination && item.action != action
);
if (duplicated !== undefined) {
openHotkeyDialog(action);
lastRecord.value = duplicated.combination;
return;
}
}
// 変更部分ここまで
changeHotkeySettings(action, setting.combination);
});
}
};
</script>
変更したコードはたったの15行です!
これだけで、「デフォルトに戻す」でも衝突チェックが行われるようになりました。
(3). アップデート可能かどうかを通知する #235 (Merged)
VOICEVOXでは活発に開発が進んでおり、最新の機能が日進月歩で追加されています。
しかしながら、ユーザーが最新のアップデートを知るすべとしてVOICEVOXホームページへ行くか、エディタの「ヘルプ」→「アップデート情報」を見に行くか、あるいはSNSでVOICEVOXの投稿を見るしかありませんでした。
他の多くのアプリケーションのように、アプリが立ち上がった時点でアップデートの通知をしてくれたり、自動で更新してくれたりすると、ユーザーはVOICEVOXの最新機能を享受しやすくなります。
このIssueを解決するまでの一連の手順をまとめてみました。
最終目標は「アプリ起動時にアップデートがあればそれを通知し、ホームページへ飛べるようにする」です。
いきなり上の機能を追加するのは難しいので、いくつかの段階に分割して進めました。
- アプリ起動時に何かしらのダイアログ1を表示する
- ダイアログ内のボタンからホームページに飛ぶ
- アップデートがある時だけダイアログを表示する
- ダイアログ内にアップデート内容を表示する
アプリ起動時に何かしらのダイアログを表示する
エディタを起動すると「エンジンを起動中・・・」「データ準備中・・・」という表示が出ます。
文字列がハードコードされてることを期待してソースコード全体を検索してみると、これらの表示はviews/EditorHome.vue
2に書かれていることがわかりました。.vue
という拡張子はVue.jsのファイル形式です。
Vue.jsの公式ドキュメントによれば、
Vue (発音は /vjuː/、view と同様) は、ユーザーインターフェースの構築のための JavaScript フレームワークです。標準的な HTML、CSS、JavaScript を土台とする、コンポーネントベースの宣言的なプログラミングモデルを提供します。シンプルなものから複雑なものまで、ユーザーインターフェースの開発を効率的に支えるフレームワークです。
確かにEditorHome.vue
を眺めてみると、HTMLのタグが書いてあるかと思えばTypeScriptやcssの記述も見つかります。どうやってコンパイルしているのか気になります。
EditorHome.vue
の<template>
内がHTMLのゾーンなので、ここをいじれば何かしらのダイアログを出せそうです。<template>
内には<q-spinner>
や<q-btn>
などq-
から始まる見慣れないタグがありました。これらのタグをググってみるとQUASARというVue.jsのフレームワークが提供するタグだと判明しました。
QUASARのコンポーネント一覧からダイアログとして使えそうなものがないか探してみると<q-dialog>
というものが見つかったので、公式サイトの例を参考にしてEditorHome.vue
に追記してみました。
ひとまず、ダイアログを表示することには成功しました。
<template>
<menu-bar />
<q-layout reveal elevated container class="layout-container">
<header-bar />
<q-page-container>
<q-page class="main-row-panes">
(略)
<div>
<q-spinner color="primary" size="2.5rem" />
<div class="q-mt-xs">
{{
allEngineState === "STARTING"
? "エンジン起動中・・・"
: "データ準備中・・・"
}}
</div>
(略)
<!-- 追加した部分 ここから -->
<q-dialog v-model="showModal">
<q-card>
<q-card-section class="text-h6">
新しいアップデートがあります!
</q-card-section>
<q-card-section>
<q-btn color="primary" label="アップデートする" @click="closeModal" />
<q-btn label="後で通知する" @click="closeModal" />
</q-card-section>
</q-card>
</q-dialog>
<!-- ここまで -->
</div>
</div>
ダイアログ内のボタンからホームページに飛ぶ
ダイアログでホームページまで誘導してあげると親切です。自動アップデートも魅力的な機能なのですが、過去のプルリクでの議論により自動アップデートは難しそうだという結論になっていたので、ホームページに飛べるような形で実装しようと思いました。
通常のHTMLではonclick属性でクリック時の動作を制御できます。QUASARのタグではどのようなオプションを提供しているのでしょうか?コードを眺めてみると@click
が頻繁に登場していることに気づきました。
@click=""
に関数を代入して使うようです。それにしても関数や変数を渡すのにダブルクォーテーションで囲むというのが珍しい印象です。HTMLではよくあることなのかもしれません。最初は文字列リテラルかと思いました。
ホームページを開く関数は、EditorHome.vue
のopenQa()
というQ&Aを開く関数を真似ました。openQa()
に気づくまでto=
でURLを指定して迷走していました。to=
を使うと指定したURLに飛ばず、リポジトリの相対パスとして認識されてしまいました。
<template>
(略)
<q-btn
padding="xs md"
label="公式サイトを開く"
unelevated
color="primary"
text-color="display-on-primary"
class="q-mt-sm"
@click="
openOfficialWebsite();
closeDialog();
"
/>
(略)
<script>
const openQa = () => {
window.open("https://voicevox.hiroshiba.jp/qa/", "_blank");
};
const openOfficialWebsite = () => {
window.open("https://voicevox.hiroshiba.jp/", "_blank");
};
アップデートがある時だけダイアログを表示する
すでに「ヘルプ」の「アップデート情報」(components/help/UpdateInfo.vue
)ではアップデートがあるかどうかの処理が実装されているので、そこを真似ればよいはずです。
アップデート可能かどうかの変数isUpdateAvailable
はこのファイルでは型が指定されているだけでどこから受け取っているかわかりません。。。
<template>
<template v-if="props.isUpdateAvailable">
<h3>最新バージョン {{ props.latestVersion }} が見つかりました</h3>
<a :href="props.downloadLink" target="_blank">ダウンロードページ</a>
<hr />
(略)
<script>
const props =
defineProps<{
latestVersion: string;
downloadLink: string;
updateInfos: UpdateInfo[];
isUpdateAvailable: boolean;
}>();
isUpdateAvailable
で検索をかけてみるとcomponents/help/HelpDialog.vue
でその値が定義されていることがわかりました。
<script>
const isUpdateAvailable = computed(() => {
return isCheckingFinished.value && latestVersion.value !== "";
});
(略)
const pagedata = computed(() => {
const data: PageData[] = [
(略)
{
type: "item",
name: "アップデート情報",
component: UpdateInfo,
props: {
downloadLink: "https://voicevox.hiroshiba.jp/",
updateInfos: updateInfos.value,
isUpdateAvailable: isUpdateAvailable.value,
latestVersion: latestVersion.value,
},
},
isUpdateAvailable
の計算で利用されているisCheckingFinished
とlatestVersion
についてはjsonファイルから情報を読み取っていました。
<script>
const isCheckingFinished = ref<boolean>(false);
// 最新版があるか調べる
const currentVersion = ref("");
const latestVersion = ref("");
window.electron
.getAppInfos()
.then((obj) => {
currentVersion.value = obj.version;
})
.then(() => {
fetch("https://api.github.com/repos/VOICEVOX/voicevox/releases", {
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
})
.then((response) => {
if (!response.ok) throw new Error("Network response was not ok.");
return response.json();
})
.then((json) => {
const newerVersion = json.find(
(item: { prerelease: boolean; tag_name: string }) => {
return (
!item.prerelease &&
semver.valid(currentVersion.value) &&
semver.valid(item.tag_name) &&
semver.lt(currentVersion.value, item.tag_name)
);
}
);
if (newerVersion) {
latestVersion.value = newerVersion.tag_name;
}
isCheckingFinished.value = true;
})
.catch((err) => {
throw new Error(err);
});
});
これら一連の処理で計算されているisUpdateAvailable
を、アップデート通知を出しているEditorHome.vue
でも使えるようにしたいので、import isUpdateAvailable from HelpDialog.vue
とでも書けばよいのだろうかと思っていたところ、開発者の方から以下のような助言を頂きました。
UpdateNotificationDialog.vueとHelpDialog.vueは依存関係がないので直接的な値の受け渡しをさせるのはコーディング的に妙なことになっちゃうかもです。
こういう時は共通処理部分を関数で切り出しちゃって、両方がその関数を実行するのが良いかなと思います!
ただこの部分はVuejsなので、一般的な関数化と違いコンポーザブルを使う必要がありそうです。
そこでisUpdateAvailable
を計算する処理をcomposables/useFetchLatestVersion.ts
として書き出しました。このファイルをUpdateNotificationDialog.vue
(EditorHome.vue
に追記した部分をコンポーネントにした)でインポートすれば利用できるようになりました。
<q-dialog v-if="isUpdateAvailable" v-model="showDialog">
<q-card class="q-py-sm q-px-md">
<q-card-section align="center">
<div class="text-h6">
<q-icon name="info" color="primary" />アップデート通知
</div>
<p>
新しいバージョンが利用可能です。<br />
公式サイトから最新版をダウンロードできます。
</p>
(略)
<script>
import { useFetchLatestVersion } from "@/composables/useFetchLatestVersion";
const { isCheckingFinished, latestVersion } = useFetchLatestVersion();
const isUpdateAvailable = computed(() => {
return isCheckingFinished.value && latestVersion.value !== "";
});
ダイアログ内にアップデート内容を表示する
「ヘルプ」の「アップデート情報」には、最新アップデートを通知するだけでなく、バージョンごとのアップデート内容が記録されていました。この機能をアップデート通知でも使いまわしたいところですが、アップデート通知ダイアログに表示したいアップデート内容は現在のアプリ以降の更新情報ですが、「ヘルプ」の「アップデート情報」に記載されているアップデート内容はアプリがビルドされた時点までのものしかありませんでした。
GitHubで管理している別のファイルに最新バージョンまでのアップデート内容が格納されていると教えていただいたので、そのファイルからアップデート内容を取得していきます。URLで指定されるjsonファイルの扱いは、アップデートの有無を判定するときに慣れたと思っていましたが、ちょっとしたところで躓きました。
アップデート内容を表示する際、現在のバージョン以降の内容だけ表示したかったので、currentVersion < info.version
という単純な比較をしていたのですが、ある入力でバグりました。バージョンは"0.13.2"という形式の文字列で管理されているのですが、単純に不等号を使って比較すると文字列の比較となり、"0.9.0"が"0.14.8"よりも大きいと判断されました。バージョンの比較にはsemver
というライブラリのlt
を使う必要があったのです。
<template v-for="(info, infoIndex) of updateInfos" :key="infoIndex">
<div v-if="semver.lt(currentVersion, info.version)">
<h6>バージョン {{ info.version }}</h6>
<ul>
<template
v-for="(item, descriptionIndex) of info.descriptions"
:key="descriptionIndex"
>
<li>{{ item }}</li>
</template>
</ul>
</div>
</template>
めでたく「アプリ起動時にアップデートがあればそれを通知し、ホームページへ飛べるようにする」という目標が達成できました!
[5]. 感想
- 世に出ている製品に自分の加えた変更が反映されると嬉しい
- 知らない関数やフレームワークに触れられる
- 読みやすいコードの書き方を学べる(or 考えさせられる)