Skip to content

Commit

Permalink
ブラウザ版e2eがたぶんエンジンなしで動く!
Browse files Browse the repository at this point in the history
  • Loading branch information
Hiroshiba committed Oct 19, 2024
1 parent 3144503 commit 0080948
Show file tree
Hide file tree
Showing 8 changed files with 531 additions and 237 deletions.
23 changes: 1 addition & 22 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { PlaywrightTestConfig, Project } from "@playwright/test";
import { z } from "zod";

import dotenv from "dotenv";
dotenv.config({ override: true });
Expand All @@ -10,26 +9,6 @@ const isElectron = process.env.VITE_TARGET === "electron";
const isBrowser = process.env.VITE_TARGET === "browser";
const isStorybook = process.env.TARGET === "storybook";

// エンジンの起動が必要
const defaultEngineInfosEnv = process.env.VITE_DEFAULT_ENGINE_INFOS ?? "[]";
const envSchema = z // FIXME: electron起動時のものと共通化したい
.object({
host: z.string(),
executionFilePath: z.string(),
executionArgs: z.array(z.string()),
executionEnabled: z.boolean(),
})
.passthrough()
.array();
const engineInfos = envSchema.parse(JSON.parse(defaultEngineInfosEnv));

const engineServers = engineInfos
.filter((info) => info.executionEnabled)
.map((info) => ({
command: `${info.executionFilePath} ${info.executionArgs.join(" ")}`,
url: `${info.host}/version`,
reuseExistingServer: !process.env.CI,
}));
const viteServer = {
command: "vite --mode test --port 7357",
port: 7357,
Expand All @@ -46,7 +25,7 @@ if (isElectron) {
webServers = [viteServer];
} else if (isBrowser) {
project = { name: "browser", testDir: "./tests/e2e/browser" };
webServers = [viteServer, ...engineServers];
webServers = [viteServer];
} else if (isStorybook) {
project = { name: "storybook", testDir: "./tests/e2e/storybook" };
webServers = [storybookServer];
Expand Down
112 changes: 112 additions & 0 deletions src/mock/engineMock/characterResourceMock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* キャラクター情報を作るモック。
* なんとなくVOICEVOX ENGINEリポジトリのモック実装と揃えている。
*/

import { assetsPath } from "./constants";
import { Speaker, SpeakerInfo } from "@/openapi";

const baseCharactersMock = [
// トーク2つ・ハミング2つ
{
name: "dummy1",
styles: [
{ name: "style0", id: 0 },
{ name: "style1", id: 2 },
{ name: "style2", id: 4, type: "frame_decode" },
{ name: "style3", id: 6, type: "frame_decode" },
],
speakerUuid: "7ffcb7ce-00ec-4bdc-82cd-45a8889e43ff",
version: "mock",
},
// トーク2つ・ハミング1つ・ソング1つ
{
name: "dummy2",
styles: [
{ name: "style0", id: 1 },
{ name: "style1", id: 3 },
{ name: "style2", id: 5, type: "frame_decode" },
{ name: "style3", id: 7, type: "sing" },
],
speakerUuid: "388f246b-8c41-4ac1-8e2d-5d79f3ff56d9",
version: "mock",
},
// トーク1つ
{
name: "dummy3",
styles: [{ name: "style0", id: 8, type: "talk" }],
speakerUuid: "35b2c544-660e-401e-b503-0e14c635303a",
version: "mock",
},
// ソング1つ
{
name: "dummy4",
styles: [{ name: "style0", id: 9, type: "sing" }],
speakerUuid: "b1a81618-b27b-40d2-b0ea-27a9ad408c4b",
version: "mock",
},
] satisfies Speaker[];

/** 喋れるキャラクターを返すモック */
export function getSpeakersMock(): Speaker[] {
return (
baseCharactersMock
// スタイルをトークのみに絞り込む
.map((character) => ({
...character,
styles: character.styles.filter(
(style) => style.type == undefined || style.type == "talk",
),
}))
// 1つもスタイルがないキャラクターを除外
.filter((character) => character.styles.length > 0)
);
}

/* 歌えるキャラクターを返すモック */
export function getSingersMock(): Speaker[] {
return (
baseCharactersMock
// スタイルをソングのみに絞り込む
.map((character) => ({
...character,
styles: character.styles.filter(
(style) => style.type == "frame_decode" || style.type == "sing",
),
}))
// 1つもスタイルがないキャラクターを除外
.filter((character) => character.styles.length > 0)
);
}

/** キャラクターの追加情報を返すモック。 */
export function getCharacterInfoMock(speakerUuid: string): SpeakerInfo {
// NOTE: 画像のURLを得るために必要
const speakerIndex = baseCharactersMock.findIndex(
(speaker) => speaker.speakerUuid === speakerUuid,
);
if (speakerIndex === -1) {
throw new Error(`Speaker not found: ${speakerUuid}`);
}

const styleIds = baseCharactersMock[speakerIndex].styles.map(
(style) => style.id,
);

return {
policy: `Dummy policy for ${speakerUuid}`,
portrait: `${assetsPath}/portrait_${speakerIndex + 1}.png`,
styleInfos: styleIds.map((id) => {
return {
id,
icon: `${assetsPath}/icon_${speakerIndex + 1}.png`,
voiceSamples: [],
};
}),
};
}

/** 喋れるキャラクターの追加情報を返すモック */
export function getSpeakerInfoMock(speakerUuid: string): SpeakerInfo {
return getCharacterInfoMock(speakerUuid);
}
62 changes: 31 additions & 31 deletions src/mock/engineMock/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { builder, IpadicFeatures, Tokenizer } from "kuromoji";
import { audioQueryToFrameAudioQueryMock } from "./audioQueryMock";
import { getEngineManifestMock } from "./manifestMock";
import { getSpeakerInfoMock, getSpeakersMock } from "./speakerResourceMock";
import {
getSingersMock,
getSpeakerInfoMock,
getSpeakersMock,
} from "./characterResourceMock";
import { synthesisFrameAudioQueryMock } from "./synthesisMock";
import {
replaceLengthMock,
replacePitchMock,
tokensToActtentPhrasesMock,
textToActtentPhrasesMock,
} from "./talkModelMock";
import {
notesAndFramePhonemesAndPitchToVolumeMock,
notesAndFramePhonemesToPitchMock,
notesToFramePhonemesMock,
} from "./singModelMock";
import { assetsPath, dicPath } from "./constants";

import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy";
import { IEngineConnectorFactory } from "@/infrastructures/EngineConnector";
Expand All @@ -27,6 +29,7 @@ import {
FrameAudioQuery,
FrameSynthesisFrameSynthesisPostRequest,
MoraDataMoraDataPostRequest,
SingerInfoSingerInfoGetRequest,
SingFrameAudioQuerySingFrameAudioQueryPostRequest,
SingFrameVolumeSingFrameVolumePostRequest,
Speaker,
Expand All @@ -53,22 +56,6 @@ export function createOpenAPIEngineMock(): IEngineConnectorFactory {
}

if (mockApi == undefined) {
// テキスト形態素解析器
const tokenizerPromise = new Promise<Tokenizer<IpadicFeatures>>(
(resolve, reject) => {
builder({ dicPath }).build(function (
err: Error,
tokenizer: Tokenizer<IpadicFeatures>,
) {
if (err) {
reject(err);
} else {
resolve(tokenizer);
}
});
},
);

mockApi = {
async versionVersionGet(): Promise<string> {
return "mock";
Expand Down Expand Up @@ -99,16 +86,26 @@ export function createOpenAPIEngineMock(): IEngineConnectorFactory {
): Promise<SpeakerInfo> {
if (payload.resourceFormat != "url")
throw new Error("resourceFormatはurl以外未対応です");
return getSpeakerInfoMock(payload.speakerUuid, assetsPath);
return getSpeakerInfoMock(payload.speakerUuid);
},

async singersSingersGet(): Promise<Speaker[]> {
return getSingersMock();
},

async singerInfoSingerInfoGet(
paload: SingerInfoSingerInfoGetRequest,
): Promise<SpeakerInfo> {
if (paload.resourceFormat != "url")
throw new Error("resourceFormatはurl以外未対応です");
return getSpeakerInfoMock(paload.speakerUuid);
},

async audioQueryAudioQueryPost(
payload: AudioQueryAudioQueryPostRequest,
): Promise<AudioQuery> {
const tokenizer = await tokenizerPromise;
const tokens = tokenizer.tokenize(payload.text);
const accentPhrases = tokensToActtentPhrasesMock(
tokens,
const accentPhrases = await textToActtentPhrasesMock(
payload.text,
payload.speaker,
);

Expand All @@ -131,10 +128,8 @@ export function createOpenAPIEngineMock(): IEngineConnectorFactory {
if (payload.isKana == true)
throw new Error("AquesTalk風記法は未対応です");

const tokenizer = await tokenizerPromise;
const tokens = tokenizer.tokenize(payload.text);
const accentPhrases = tokensToActtentPhrasesMock(
tokens,
const accentPhrases = await textToActtentPhrasesMock(
payload.text,
payload.speaker,
);
return accentPhrases;
Expand All @@ -159,10 +154,11 @@ export function createOpenAPIEngineMock(): IEngineConnectorFactory {
payload.enableInterrogativeUpspeak ?? false,
},
);
return synthesisFrameAudioQueryMock(
const buffer = synthesisFrameAudioQueryMock(
frameAudioQuery,
payload.speaker,
);
return new Blob([buffer], { type: "audio/wav" });
},

async singFrameAudioQuerySingFrameAudioQueryPost(
Expand Down Expand Up @@ -218,7 +214,11 @@ export function createOpenAPIEngineMock(): IEngineConnectorFactory {
): Promise<Blob> {
const { speaker: styleId, frameAudioQuery } =
cloneWithUnwrapProxy(payload);
return synthesisFrameAudioQueryMock(frameAudioQuery, styleId);
const buffer = synthesisFrameAudioQueryMock(
frameAudioQuery,
styleId,
);
return new Blob([buffer], { type: "audio/wav" });
},
};
}
Expand Down
23 changes: 14 additions & 9 deletions src/mock/engineMock/singModelMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
import { moraToPhonemes } from "./phonemeMock";
import { convertHiraToKana } from "@/domain/japanese";
import { Note, FramePhoneme } from "@/openapi";
import { noteNumberToFrequency } from "@/sing/domain";

function noteNumberToFrequency(noteNumber: number) {
return 440 * Math.pow(2, (noteNumber - 69) / 12);
}

/** アルファベット文字列を適当な0~1の適当な数値に変換する */
function alphabetsToNumber(text: string): number {
Expand Down Expand Up @@ -150,15 +153,17 @@ export function notesAndFramePhonemesAndPitchToVolumeMock(
Array<string>(phoneme.frameLength).fill(phoneme.phoneme),
);

return Array<number>(f0.length).map((_, i) => {
const phoneme = phonemePerFrame[i];
const pitch = f0[i];
return Array<number>(f0.length)
.fill(-1)
.map((_, i) => {
const phoneme = phonemePerFrame[i];
const pitch = f0[i];

let volume = phonemeAndPitchToVolumeMock(phoneme, pitch);
let volume = phonemeAndPitchToVolumeMock(phoneme, pitch);

// 別の歌手で同じにならないように適当に値をずらす
volume *= 1 - styleId * 0.03;
// 別の歌手で同じにならないように適当に値をずらす
volume *= 1 - styleId * 0.03;

return volume;
});
return volume;
});
}
Loading

0 comments on commit 0080948

Please sign in to comment.