From ccc278207327a373ed786b75796b9b10f44a3307 Mon Sep 17 00:00:00 2001 From: Tomachi <8929706+book000@users.noreply.github.com> Date: Sun, 14 Jul 2024 21:18:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=98=E3=83=83=E3=83=80=E3=83=BC?= =?UTF-8?q?=E8=A1=8C=E3=81=AE=E3=81=BF=E3=81=AE=E5=A0=B4=E5=90=88=E3=80=81?= =?UTF-8?q?=E7=89=B9=E6=AE=8A=E8=AA=AD=E3=81=BF=E4=B8=8A=E3=81=92=E3=82=92?= =?UTF-8?q?=E8=A1=8C=E3=81=86=E6=A9=9F=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20(#182)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ヘッダー行のみの場合、特殊読み上げを行う機能を追加 * fix: removed debug print * fix: あたらしいテキストの処理を修正 * chore: prepare for adding H1,2,3 * feat: leveled heading --------- Co-authored-by: yuua --- .../jaoafa/vcspeaker/commands/VoiceCommand.kt | 2 +- .../com/jaoafa/vcspeaker/stores/GuildStore.kt | 3 +- .../com/jaoafa/vcspeaker/stores/VoiceStore.kt | 3 +- .../kotlin/com/jaoafa/vcspeaker/tts/Voice.kt | 2 +- .../com/jaoafa/vcspeaker/tts/markdown/Line.kt | 16 +- .../tts/processors/InlineVoiceProcessor.kt | 2 +- .../tts/processors/MarkdownFormatProcessor.kt | 20 +- .../processors/MarkdownFormatProcessorTest.kt | 463 +++++++++++------- 8 files changed, 329 insertions(+), 182 deletions(-) diff --git a/src/main/kotlin/com/jaoafa/vcspeaker/commands/VoiceCommand.kt b/src/main/kotlin/com/jaoafa/vcspeaker/commands/VoiceCommand.kt index 502a7c8f..30b792c8 100644 --- a/src/main/kotlin/com/jaoafa/vcspeaker/commands/VoiceCommand.kt +++ b/src/main/kotlin/com/jaoafa/vcspeaker/commands/VoiceCommand.kt @@ -83,7 +83,7 @@ class VoiceCommand : Extension() { if (it == "none") null else Emotion.valueOf(it) } - val newVoice = oldVoice.overwrite( + val newVoice = oldVoice.copyNotNull( speaker = arguments.speaker?.let { Speaker.valueOf(it) }, emotion = emotion, emotionLevel = if (emotion != null) arguments.emotionLevel else null, diff --git a/src/main/kotlin/com/jaoafa/vcspeaker/stores/GuildStore.kt b/src/main/kotlin/com/jaoafa/vcspeaker/stores/GuildStore.kt index 1dbc9384..dd7c772b 100644 --- a/src/main/kotlin/com/jaoafa/vcspeaker/stores/GuildStore.kt +++ b/src/main/kotlin/com/jaoafa/vcspeaker/stores/GuildStore.kt @@ -1,11 +1,10 @@ package com.jaoafa.vcspeaker.stores import com.jaoafa.vcspeaker.VCSpeaker -import com.jaoafa.vcspeaker.tts.api.Speaker import com.jaoafa.vcspeaker.tts.Voice +import com.jaoafa.vcspeaker.tts.api.Speaker import dev.kord.common.entity.Snowflake import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @Serializable diff --git a/src/main/kotlin/com/jaoafa/vcspeaker/stores/VoiceStore.kt b/src/main/kotlin/com/jaoafa/vcspeaker/stores/VoiceStore.kt index 8914036a..e7e983bc 100644 --- a/src/main/kotlin/com/jaoafa/vcspeaker/stores/VoiceStore.kt +++ b/src/main/kotlin/com/jaoafa/vcspeaker/stores/VoiceStore.kt @@ -1,11 +1,10 @@ package com.jaoafa.vcspeaker.stores import com.jaoafa.vcspeaker.VCSpeaker -import com.jaoafa.vcspeaker.tts.api.Speaker import com.jaoafa.vcspeaker.tts.Voice +import com.jaoafa.vcspeaker.tts.api.Speaker import dev.kord.common.entity.Snowflake import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @Serializable diff --git a/src/main/kotlin/com/jaoafa/vcspeaker/tts/Voice.kt b/src/main/kotlin/com/jaoafa/vcspeaker/tts/Voice.kt index 93ca4e1c..e18cda27 100644 --- a/src/main/kotlin/com/jaoafa/vcspeaker/tts/Voice.kt +++ b/src/main/kotlin/com/jaoafa/vcspeaker/tts/Voice.kt @@ -17,7 +17,7 @@ data class Voice( ) { fun toJson() = Json.encodeToString(serializer(), this) - fun overwrite( + fun copyNotNull( speaker: Speaker? = null, emotion: Emotion? = null, emotionLevel: Int? = null, diff --git a/src/main/kotlin/com/jaoafa/vcspeaker/tts/markdown/Line.kt b/src/main/kotlin/com/jaoafa/vcspeaker/tts/markdown/Line.kt index 7c2ab95d..1d4becd2 100644 --- a/src/main/kotlin/com/jaoafa/vcspeaker/tts/markdown/Line.kt +++ b/src/main/kotlin/com/jaoafa/vcspeaker/tts/markdown/Line.kt @@ -3,21 +3,25 @@ package com.jaoafa.vcspeaker.tts.markdown data class Line(val inlines: List, val effects: Set) { companion object { fun from(paragraph: String): Line { - val inlines = Inline.from(paragraph) - val plainText = inlines.joinToString("") { it.text } - val prefixCandidates = plainText.split(" ").filter { it.isNotEmpty() } + val prefixCandidates = paragraph.split(" ").filter { it.isNotEmpty() } val effects = mutableSetOf() var skipped = false for (prefixCandidate in prefixCandidates) { + if (skipped) break + // null if this is not a prefix val prefix = LineEffect.entries.firstOrNull { it.regex.matches(prefixCandidate) } - if (prefix != null && !skipped) effects.add(prefix) + if (prefix != null) effects.add(prefix) else skipped = true } + val text = prefixCandidates.drop(effects.size).joinToString(" ") + + val inlines = Inline.from(text) + return Line(inlines, effects) } } @@ -30,7 +34,9 @@ data class Line(val inlines: List, val effects: Set) { } enum class LineEffect(val regex: Regex) { - Header(Regex("^#{1,3}$")), + Heading1(Regex("^#$")), + Heading2(Regex("^##$")), + Heading3(Regex("^###$")), Quote(Regex("^>$")), BulletList(Regex("^[*-]$")), NumberedList(Regex("^\\d+\\.$")) diff --git a/src/main/kotlin/com/jaoafa/vcspeaker/tts/processors/InlineVoiceProcessor.kt b/src/main/kotlin/com/jaoafa/vcspeaker/tts/processors/InlineVoiceProcessor.kt index aafc6fe4..aca81306 100644 --- a/src/main/kotlin/com/jaoafa/vcspeaker/tts/processors/InlineVoiceProcessor.kt +++ b/src/main/kotlin/com/jaoafa/vcspeaker/tts/processors/InlineVoiceProcessor.kt @@ -19,7 +19,7 @@ class InlineVoiceProcessor : BaseProcessor() { key to value }.toMap() - val newVoice = voice.overwrite( + val newVoice = voice.copyNotNull( speaker = parameterMap["speaker"]?.let { Speaker.valueOf(it.capitalizeWords()) }, emotion = parameterMap["emotion"]?.let { Emotion.valueOf(it.capitalizeWords()) }, emotionLevel = parameterMap["emotion_level"]?.toIntOrNull(), diff --git a/src/main/kotlin/com/jaoafa/vcspeaker/tts/processors/MarkdownFormatProcessor.kt b/src/main/kotlin/com/jaoafa/vcspeaker/tts/processors/MarkdownFormatProcessor.kt index 58384c72..447db4b0 100644 --- a/src/main/kotlin/com/jaoafa/vcspeaker/tts/processors/MarkdownFormatProcessor.kt +++ b/src/main/kotlin/com/jaoafa/vcspeaker/tts/processors/MarkdownFormatProcessor.kt @@ -1,15 +1,29 @@ package com.jaoafa.vcspeaker.tts.processors import com.jaoafa.vcspeaker.tts.Voice +import com.jaoafa.vcspeaker.tts.markdown.LineEffect import com.jaoafa.vcspeaker.tts.markdown.toMarkdown import dev.kord.core.entity.Message class MarkdownFormatProcessor : BaseProcessor() { - override val priority = 60 + override val priority = 80 override suspend fun process(message: Message?, content: String, voice: Voice): Pair { - val markdown = content.toMarkdown().joinToString("") { it.toReadable() } + val markdown = content.toMarkdown() - return markdown to voice + val heading = markdown.first().effects.firstOrNull { it.name.startsWith("Heading") } + + val newVoice = if (markdown.size == 1 && heading != null) { + when (heading) { + LineEffect.Heading1 -> voice.copy(speed = 200) + LineEffect.Heading2 -> voice.copy(speed = 175) + LineEffect.Heading3 -> voice.copy(speed = 150) + else -> voice + } + } else voice + + val readableMarkdown = markdown.joinToString(" ") { it.toReadable() } + + return readableMarkdown to newVoice } } \ No newline at end of file diff --git a/src/test/kotlin/processors/MarkdownFormatProcessorTest.kt b/src/test/kotlin/processors/MarkdownFormatProcessorTest.kt index 30a4efb8..36f94fa7 100644 --- a/src/test/kotlin/processors/MarkdownFormatProcessorTest.kt +++ b/src/test/kotlin/processors/MarkdownFormatProcessorTest.kt @@ -18,174 +18,303 @@ class MarkdownFormatProcessorTest : FunSpec({ clearAllMocks() } - // インラインの太字マークダウンから変換されたコンテンツを返す - test("If the markdown message contains bold phrases, the affixes should be removed.") { - val message = mockk() - val processor = MarkdownFormatProcessor() - val voice = Voice(speaker = Speaker.Hikari) - - val (processedText, processedVoice) = processor.process( - message, - "**bold**", - voice - ) - - processedText shouldBe "bold" - processedVoice shouldBe voice + context("Make Markdown readable.") { + // インラインの太字マークダウンから変換されたコンテンツを返す + test("If the markdown message contains bold phrases, the affixes should be removed.") { + val message = mockk() + val processor = MarkdownFormatProcessor() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = processor.process( + message, + "**bold**", + voice + ) + + processedText shouldBe "bold" + processedVoice shouldBe voice + } + + // インラインの斜体マークダウンから変換されたコンテンツを返す + test("If the markdown message contains italic phrases, the affixes should be removed.") { + val message = mockk() + val processor = MarkdownFormatProcessor() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = processor.process( + message, + "*italic*", + voice + ) + + processedText shouldBe "italic" + processedVoice shouldBe voice + } + + // インラインの取り消し線マークダウンから変換されたコンテンツを返す + test("If the markdown message contains strike-through phrases, they should be redacted.") { + val message = mockk() + val processor = MarkdownFormatProcessor() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = processor.process( + message, + "~~strike-through~~", + voice + ) + + processedText shouldBe "パー" + processedVoice shouldBe voice + } + + // インラインの下線マークダウンから変換されたコンテンツを返す + test("If the markdown message contains underlined phrases, the affixes should be removed.") { + val message = mockk() + val processor = MarkdownFormatProcessor() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = processor.process( + message, + "__underline__", + voice + ) + + processedText shouldBe "underline" + processedVoice shouldBe voice + } + + // インラインのコードマークダウンから変換されたコンテンツを返す + test("If the markdown message contains inline codes, the affixes should be removed.") { + val message = mockk() + val processor = MarkdownFormatProcessor() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = processor.process( + message, + "`code`", + voice + ) + + processedText shouldBe "code" + processedVoice shouldBe voice + } + + // インラインのリンクマークダウンから変換されたコンテンツを返す + test("If the markdown message contains hyperlinks, the affixes and URLs should be removed.") { + val message = mockk() + val processor = MarkdownFormatProcessor() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = processor.process( + message, + "[link](https://example.com)", + voice + ) + + processedText shouldBe "link" + processedVoice shouldBe voice + } + + // インラインの引用マークダウンから変換されたコンテンツを返す + test("If the markdown message contains spoilers, they should be redacted.") { + val message = mockk() + val processor = MarkdownFormatProcessor() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = processor.process( + message, + "||spoiler||", + voice + ) + + processedText shouldBe "ピー" + processedVoice shouldBe voice + } + + // ブロックマークダウンは改行とコードブロックを除去して返す + test("If the markdown message contains code blocks, they should be removed.") { + val message = mockk() + val processor = MarkdownFormatProcessor() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = processor.process( + message, """ + # Header 1 + ## Header 2 + ### Header 3 + #### Header 4 + ##### Header 5 + ###### Header 6 + - List item 1 + - List item 2 + - List item 3 + 1. Numbered list item 1 + 2. Numbered list item 2 + 3. Numbered list item 3 + > Blockquote + `Code` + ```kotlin + fun main() { + println("Hello, world!") + } + ``` + """.trimIndent(), voice + ) + + processedText shouldBe "Header 1 Header 2 Header 3 #### Header 4 ##### Header 5 ###### Header 6 List item 1 List item 2 List item 3 Numbered list item 1 Numbered list item 2 Numbered list item 3 Blockquote Code" + processedVoice shouldBe voice + } + + // マークダウンがない場合、変更なしのコンテンツを返す + test("If the message contains no markdown syntax, the text should be remain unchanged.") { + val message = mockk() + val processor = MarkdownFormatProcessor() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = processor.process(message, "no markdown here", voice) + + processedText shouldBe "no markdown here" + processedVoice shouldBe voice + } + + // 空の文字列の場合、変更なしのコンテンツを返す + test("If the message is empty, the text should be remain unchanged.") { + val message = mockk() + val processor = MarkdownFormatProcessor() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = processor.process(message, "", voice) + + processedText shouldBe "" + processedVoice shouldBe voice + } } - // インラインの斜体マークダウンから変換されたコンテンツを返す - test("If the markdown message contains italic phrases, the affixes should be removed.") { - val message = mockk() - val processor = MarkdownFormatProcessor() - val voice = Voice(speaker = Speaker.Hikari) - - val (processedText, processedVoice) = processor.process( - message, - "*italic*", - voice - ) - - processedText shouldBe "italic" - processedVoice shouldBe voice - } - - // インラインの取り消し線マークダウンから変換されたコンテンツを返す - test("If the markdown message contains strike-through phrases, they should be redacted.") { - val message = mockk() - val processor = MarkdownFormatProcessor() - val voice = Voice(speaker = Speaker.Hikari) - - val (processedText, processedVoice) = processor.process( - message, - "~~strike-through~~", - voice - ) - - processedText shouldBe "パー" - processedVoice shouldBe voice - } - - // インラインの下線マークダウンから変換されたコンテンツを返す - test("If the markdown message contains underlined phrases, the affixes should be removed.") { - val message = mockk() - val processor = MarkdownFormatProcessor() - val voice = Voice(speaker = Speaker.Hikari) - - val (processedText, processedVoice) = processor.process( - message, - "__underline__", - voice - ) - - processedText shouldBe "underline" - processedVoice shouldBe voice - } - - // インラインのコードマークダウンから変換されたコンテンツを返す - test("If the markdown message contains inline codes, the affixes should be removed.") { - val message = mockk() - val processor = MarkdownFormatProcessor() - val voice = Voice(speaker = Speaker.Hikari) - - val (processedText, processedVoice) = processor.process( - message, - "`code`", - voice - ) - - processedText shouldBe "code" - processedVoice shouldBe voice - } - - // インラインのリンクマークダウンから変換されたコンテンツを返す - test("If the markdown message contains hyperlinks, the affixes and URLs should be removed.") { - val message = mockk() - val processor = MarkdownFormatProcessor() - val voice = Voice(speaker = Speaker.Hikari) - - val (processedText, processedVoice) = processor.process( - message, - "[link](https://example.com)", - voice - ) - - processedText shouldBe "link" - processedVoice shouldBe voice - } - - // インラインの引用マークダウンから変換されたコンテンツを返す - test("If the markdown message contains spoilers, they should be redacted.") { - val message = mockk() - val processor = MarkdownFormatProcessor() - val voice = Voice(speaker = Speaker.Hikari) - - val (processedText, processedVoice) = processor.process( - message, - "||spoiler||", - voice - ) - - processedText shouldBe "ピー" - processedVoice shouldBe voice - } - - // ブロックマークダウンは改行とコードブロックを除去して返す - test("If the markdown message contains code blocks, they should be removed.") { - val message = mockk() - val processor = MarkdownFormatProcessor() - val voice = Voice(speaker = Speaker.Hikari) - - val (processedText, processedVoice) = processor.process( - message, """ - # Header 1 - ## Header 2 - ### Header 3 - #### Header 4 - ##### Header 5 - ###### Header 6 - - List item 1 - - List item 2 - - List item 3 - 1. Numbered list item 1 - 2. Numbered list item 2 - 3. Numbered list item 3 - > Blockquote - `Code` - ```kotlin - fun main() { - println("Hello, world!") - } - ``` - """.trimIndent(), voice - ) - - // TODO ここは実装に改良の余地がありそう。改行を消すときにスペースくらい残さないと、前の文字とくっついてしまう - processedText shouldBe "# Header 1## Header 2### Header 3#### Header 4##### Header 5###### Header 6- List item 1- List item 2- List item 31. Numbered list item 12. Numbered list item 23. Numbered list item 3> BlockquoteCode" - processedVoice shouldBe voice - } - - // マークダウンがない場合、変更なしのコンテンツを返す - test("If the message contains no markdown syntax, the text should be remain unchanged.") { - val message = mockk() - val processor = MarkdownFormatProcessor() - val voice = Voice(speaker = Speaker.Hikari) - - val (processedText, processedVoice) = processor.process(message, "no markdown here", voice) - - processedText shouldBe "no markdown here" - processedVoice shouldBe voice - } - - // 空の文字列の場合、変更なしのコンテンツを返す - test("If the message is empty, the text should be remain unchanged.") { - val message = mockk() - val processor = MarkdownFormatProcessor() - val voice = Voice(speaker = Speaker.Hikari) - - val (processedText, processedVoice) = processor.process(message, "", voice) - - processedText shouldBe "" - processedVoice shouldBe voice + context("Change reading speed by header level.") { + // レベル1のヘッダー行のみの場合、速度が変更されること + test("If the markdown message contains only a level 1 header line, the speed should be changed.") { + val message = mockk() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = MarkdownFormatProcessor().process( + message, + "# header", + voice + ) + + processedText shouldBe "header" + processedVoice shouldBe voice.copy(speed = 200) + } + + // レベル2のヘッダー行のみの場合、速度が変更されること + test("If the markdown message contains only a level 2 header line, the speed should be changed.") { + val message = mockk() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = MarkdownFormatProcessor().process( + message, + "## header", + voice + ) + + processedText shouldBe "header" + processedVoice shouldBe voice.copy(speed = 175) + } + + // レベル3のヘッダー行のみの場合、速度が変更されること + test("If the markdown message contains only a level 3 header line, the speed should be changed.") { + val message = mockk() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = MarkdownFormatProcessor().process( + message, + "### header", + voice + ) + + processedText shouldBe "header" + processedVoice shouldBe voice.copy(speed = 150) + } + + // レベル4のヘッダー行のみの場合、速度が変更されないこと + test("If the markdown message contains only a level 4 header line, the speed should not be changed.") { + val message = mockk() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = MarkdownFormatProcessor().process( + message, + "#### header", + voice + ) + + processedText shouldBe "#### header" + processedVoice shouldBe voice + } + + // ヘッダー行では無い通常テキスト場合、速度が変更されないこと + test("If the markdown message does not contain a header line, the speed should not be changed.") { + val message = mockk() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = MarkdownFormatProcessor().process( + message, + "not header", + voice + ) + + processedText shouldBe "not header" + processedVoice shouldBe voice + } + + // ヘッダー行内に他の効果がある場合、速度が変更されるが、フォーマットは削除されること + test("If the markdown message contains other effects in the header line, the speed should be changed, but the format should be removed.") { + val message = mockk() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = MarkdownFormatProcessor().process( + message, + "# **header**", + voice + ) + + processedText shouldBe "header" + processedVoice shouldBe voice.copy(speed = 200) + } + + // ヘッダー行以外の行がある場合、速度が変更されないこと + test("If there are lines other than the header line, the speed should not be changed.") { + val message = mockk() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = MarkdownFormatProcessor().process( + message, + """ + # header + not header + """.trimIndent(), + voice + ) + + processedText shouldBe "header not header" + processedVoice shouldBe voice + } + + // ヘッダー行が複数ある場合、速度が変更されないこと + test("If there are multiple header lines, the speed should not be changed.") { + val message = mockk() + val voice = Voice(speaker = Speaker.Hikari) + + val (processedText, processedVoice) = MarkdownFormatProcessor().process( + message, + """ + # header1 + # header2 + """.trimIndent(), + voice + ) + + processedText shouldBe "header1 header2" + processedVoice shouldBe voice + } } }) \ No newline at end of file