Skip to content

Commit

Permalink
Merge pull request #101 from CXwudi/mka-extract-and-tag
Browse files Browse the repository at this point in the history
Separate the ExtractorDecider
  • Loading branch information
CXwudi authored Dec 14, 2024
2 parents 4958119 + e528c2c commit 292351f
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 28 deletions.
2 changes: 0 additions & 2 deletions docker/docker-compose.base.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
version: '3.7'

services:
base:
image: vvd-env
Expand Down
2 changes: 0 additions & 2 deletions docker/docker-compose.debug-test-all.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
# this compose file should be run together with .base.yml file
version: '3.7'

services:
base:
container_name: vvd-debug-test-all
Expand Down
2 changes: 0 additions & 2 deletions docker/docker-compose.test-all.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
# this compose file should be run together with .base.yml file
version: '3.7'

services:
base:
container_name: vvd-test-all
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mikufan.cx.vvd.extractor.component

import mikufan.cx.inlinelogging.KInlineLogging
import mikufan.cx.vvd.common.exception.RuntimeVocaloidException
import mikufan.cx.vvd.extractor.component.extractor.base.BaseAudioExtractor
import mikufan.cx.vvd.extractor.component.extractor.impl.AacToM4aAudioExtractor
import mikufan.cx.vvd.extractor.component.extractor.impl.OpusToOggAudioExtractor
import mikufan.cx.vvd.extractor.config.IOConfig
Expand All @@ -19,53 +20,68 @@ import kotlin.io.path.div
import kotlin.io.path.exists
import kotlin.io.path.notExists

@Component
@Order(OrderConstants.EXTRACTOR_DECIDER_ORDER)
class ExtractorDecider(
private val extractorDeciderCore: ExtractorDeciderCore
) : RecordProcessor<VSongTask, VSongTask> {

override fun processRecord(record: Record<VSongTask>): Record<VSongTask> {
val task = record.payload
val extractor = extractorDeciderCore.decideExtractor(
audioFileName = task.label.audioFileName,
videoFileName = task.label.pvFileName,
baseFileName = task.parameters.songProperFileName.toString()
)
task.parameters.chosenAudioExtractor = Optional.ofNullable(extractor)
return record
}
}


/**
* @date 2022-07-01
* @author CX无敌
*/
@Component
@Order(OrderConstants.EXTRACTOR_DECIDER_ORDER)
class ExtractorDecider(
class ExtractorDeciderCore(
ioConfig: IOConfig,
private val audioMediaFormatChecker: MediaFormatChecker,
private val ctx: ApplicationContext,
) : RecordProcessor<VSongTask, VSongTask> {
) {

private val inputDirectory = ioConfig.inputDirectory

override fun processRecord(record: Record<VSongTask>): Record<VSongTask> {
val baseFileName = record.payload.parameters.songProperFileName
fun decideExtractor(audioFileName: String? = null, videoFileName: String, baseFileName: String): BaseAudioExtractor? {
log.info { "Start deciding the best audio extractor for $baseFileName" }
// if the label contains a valid audio file, then skip extraction
val audioFileName: String? = record.payload.label.audioFileName

if (!audioFileName.isNullOrBlank()) {
val audioFile = inputDirectory / audioFileName
if (audioFile.exists()) {
log.info { "Skip choosing audio extractor for $baseFileName as it contains an audio file $audioFile" }
record.payload.parameters.chosenAudioExtractor = Optional.empty()
return record
return null
} else {
log.warn { "Audio file $audioFileName is declared in label json file but doesn't exist in inout directory, trading as no audio file" }
log.warn { "Audio file $audioFileName is declared but doesn't exist in input directory, treating as no audio file" }
}
}

val pvFile = inputDirectory / record.payload.label.pvFileName
val pvFile = inputDirectory / videoFileName
if (pvFile.notExists()) {
throw RuntimeVocaloidException(
"pv file not found: ${pvFile.absolute()} for song $baseFileName. " +
"Nor does it has a valid audio file."
)
}

val chosenAudioExtractor = when (val audioFormat = audioMediaFormatChecker.checkAudioFormat(pvFile)) {
return when (val audioFormat = audioMediaFormatChecker.checkAudioFormat(pvFile)) {
"aac" -> ctx.getBean<AacToM4aAudioExtractor>()
"opus" -> ctx.getBean<OpusToOggAudioExtractor>()
else -> throw RuntimeVocaloidException("Unsupported audio format $audioFormat for song $baseFileName")
}.also {
log.info { "Decided to use ${it.name} to extract audio from $baseFileName" }
}
log.info { "Decided to use ${chosenAudioExtractor.name} to extract audio from $baseFileName" }
record.payload.parameters.chosenAudioExtractor = Optional.of(chosenAudioExtractor)
return record
}
}


private val log = KInlineLogging.logger()
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists

/**
* The lossless audio extractor for any video with AAC LC audio track.
* The lossless audio extractor for video with AAC LC audio track.
* Extracted audio will be in m4a format.
*
* It execute two commands:
* 1. ffmpeg -i input.mp4 -vn -acodec copy -y temp.aac
* 2. ffmpeg -i temp.aac -vn --acodec copy -y -movflags +faststart output.m4a
* It executes two commands:
* 1. `ffmpeg -i input.mp4 -vn -acodec copy -y temp.aac` to extract the raw audio stream
* 2. `ffmpeg -i temp.aac -vn --acodec copy -y -movflags +faststart output.m4a`
* to package the audio stream into m4a container with iTunes style faststart flag
*
* @date 2022-07-16
* @author CX无敌
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import java.util.concurrent.ThreadPoolExecutor
import kotlin.io.path.exists

/**
* The lossless audio extractor for any video with opus audio track (or any ogg/opus related audio codec).
* The lossless audio extractor for video with opus audio track (or any ogg/opus related audio codec).
* Extracted audio will be in ogg format.
* Although it is preferred to extracted as opus, but since NetEase Cloud Music does not support opus, it will be extracted as ogg.
* Although it is preferred to be extracted as opus,
* but since NetEase Cloud Music does not support opus, it will be extracted as ogg.
*
* It will run this command:
* ffmpeg -i input.mkv -vn -acodec copy output.ogg
* `ffmpeg -i input.mkv -vn -acodec copy output.ogg`
*
* @date 2022-07-16
* @author CX无敌
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package mikufan.cx.vvd.extractor.component

import io.kotest.assertions.fail
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
import io.mockk.mockk
import mikufan.cx.vvd.common.exception.RuntimeVocaloidException
import mikufan.cx.vvd.extractor.component.extractor.base.BaseAudioExtractor
import mikufan.cx.vvd.extractor.component.extractor.impl.AacToM4aAudioExtractor
import mikufan.cx.vvd.extractor.component.extractor.impl.OpusToOggAudioExtractor
import mikufan.cx.vvd.extractor.config.IOConfig
import org.springframework.beans.factory.getBean
import org.springframework.context.ApplicationContext
import java.nio.file.Files
import kotlin.io.path.createFile
import kotlin.io.path.deleteExisting
import kotlin.io.path.div

class ExtractorDeciderCoreTest : ShouldSpec({

context("extractor decider") {
val tempInputDir = Files.createTempDirectory("extractor-core-test-")
val ioConfig = mockk<IOConfig> {
every { inputDirectory } returns tempInputDir
}
val baseInputFileName = "fake input file"

val mockCtx = mockk<ApplicationContext> {
every { getBean<AacToM4aAudioExtractor>() } returns mockk {
every { name } returns "Mock AAC to M4A Audio Extractor"
}
every { getBean<OpusToOggAudioExtractor>() } returns mockk {
every { name } returns "Mock Opus to Ogg Audio Extractor"
}
}

should("not set audio extractor if using audio file") {
val audioFileName = "$baseInputFileName.aac"
val audioFile = tempInputDir / audioFileName
audioFile.createFile()

val extractorDeciderCore = ExtractorDeciderCore(ioConfig, mockk(), mockk())

val decideExtractor: BaseAudioExtractor? = extractorDeciderCore.decideExtractor(audioFileName, "", baseInputFileName)

decideExtractor.shouldBeNull()
audioFile.deleteExisting()
}

context("on pv files") {
listOf("aac", "opus").forEach { format ->
val mockChecker = mockk<MediaFormatChecker> {
every { checkAudioFormat(any()) } returns format
}

val pvFileName = "$baseInputFileName.mp4"
val pvFile = tempInputDir / pvFileName
pvFile.createFile()

should("set the correct extractor for $format format") {
val extractorDeciderCore = ExtractorDeciderCore(ioConfig, mockChecker, mockCtx)
val decideExtractor: BaseAudioExtractor? = extractorDeciderCore.decideExtractor("", pvFileName, baseInputFileName)
decideExtractor.shouldNotBeNull()
when (format) {
"aac" -> decideExtractor.shouldBeInstanceOf<AacToM4aAudioExtractor>()
"opus" -> decideExtractor.shouldBeInstanceOf<OpusToOggAudioExtractor>()
else -> fail("Unknown format $format")
}
}
pvFile.deleteExisting()
}
}

should("fails if encounter an unknown pv file format") {
val mockChecker = mockk<MediaFormatChecker> {
every { checkAudioFormat(any()) } returns "wired format"
}

val pvFileName = "$baseInputFileName.mp4"
val pvFile = tempInputDir / pvFileName
pvFile.createFile()

val extractorDeciderCore = ExtractorDeciderCore(ioConfig, mockChecker, mockCtx)
val exception = shouldThrow<RuntimeVocaloidException> {
extractorDeciderCore.decideExtractor("", pvFileName, baseInputFileName)
}
exception.message shouldContain "Unsupported audio format"

pvFile.deleteExisting()
}

should("fails if neither audio file nor pv file exists") {
val extractorDeciderCore = ExtractorDeciderCore(ioConfig, mockk(), mockk())
val exception = shouldThrow<RuntimeVocaloidException> {
extractorDeciderCore.decideExtractor("fake.mp3", "fake.mp4", baseInputFileName)
}

exception.message shouldContain "pv file not found"
}
}
})

0 comments on commit 292351f

Please sign in to comment.