diff --git a/binary-compatibility-validator/android/api/binary-compatibility-validator-android.api b/binary-compatibility-validator/android/api/binary-compatibility-validator-android.api
index 9691f9abc..639b15e8a 100644
--- a/binary-compatibility-validator/android/api/binary-compatibility-validator-android.api
+++ b/binary-compatibility-validator/android/api/binary-compatibility-validator-android.api
@@ -5709,6 +5709,7 @@ public abstract interface class net/mamoe/mirai/utils/ExternalResource : java/io
 	public abstract fun getClosed ()Lkotlinx/coroutines/Deferred;
 	public abstract fun getFormatName ()Ljava/lang/String;
 	public abstract fun getMd5 ()[B
+	public fun getOrigin ()Ljava/lang/Object;
 	public fun getSha1 ()[B
 	public abstract fun getSize ()J
 	public abstract fun inputStream ()Ljava/io/InputStream;
diff --git a/binary-compatibility-validator/api/binary-compatibility-validator.api b/binary-compatibility-validator/api/binary-compatibility-validator.api
index ec677c124..b76047234 100644
--- a/binary-compatibility-validator/api/binary-compatibility-validator.api
+++ b/binary-compatibility-validator/api/binary-compatibility-validator.api
@@ -5709,6 +5709,7 @@ public abstract interface class net/mamoe/mirai/utils/ExternalResource : java/io
 	public abstract fun getClosed ()Lkotlinx/coroutines/Deferred;
 	public abstract fun getFormatName ()Ljava/lang/String;
 	public abstract fun getMd5 ()[B
+	public fun getOrigin ()Ljava/lang/Object;
 	public fun getSha1 ()[B
 	public abstract fun getSize ()J
 	public abstract fun inputStream ()Ljava/io/InputStream;
diff --git a/mirai-core-api/src/commonMain/kotlin/internal/utils/ExternalImageImpls.kt b/mirai-core-api/src/commonMain/kotlin/internal/utils/ExternalImageImpls.kt
index 0c02a3085..edaef541a 100644
--- a/mirai-core-api/src/commonMain/kotlin/internal/utils/ExternalImageImpls.kt
+++ b/mirai-core-api/src/commonMain/kotlin/internal/utils/ExternalImageImpls.kt
@@ -40,6 +40,10 @@ internal class ExternalResourceImplByFileWithMd5(
         }
     }
 
+    override var origin: Any? = null
+        internal set
+
+
     override val holder: ResourceHolder = ResourceHolder(file)
 
     override val sha1: ByteArray by lazy { inputStream().sha1() }
@@ -97,8 +101,11 @@ internal interface ExternalResourceInternal : ExternalResource {
 internal class ExternalResourceImplByFile(
     private val file: RandomAccessFile,
     formatName: String?,
-    closeOriginalFileOnClose: Boolean = true
+    closeOriginalFileOnClose: Boolean = true,
 ) : ExternalResourceInternal {
+    override var origin: Any? = null
+        internal set
+
     internal class ResourceHolder(
         @JvmField internal val closeOriginalFileOnClose: Boolean,
         @JvmField internal val file: RandomAccessFile,
@@ -146,6 +153,8 @@ internal class ExternalResourceImplByByteArray(
         ?: ExternalResource.DEFAULT_FORMAT_NAME
     }
     override val closed: CompletableDeferred<Unit> = CompletableDeferred()
+    override val origin: Any
+        get() = data//.clone()
 
     override fun inputStream(): InputStream = data.inputStream()
     override fun close() {
diff --git a/mirai-core-api/src/commonMain/kotlin/spi/AudioToSilkService.kt b/mirai-core-api/src/commonMain/kotlin/spi/AudioToSilkService.kt
new file mode 100644
index 000000000..717e7b0f5
--- /dev/null
+++ b/mirai-core-api/src/commonMain/kotlin/spi/AudioToSilkService.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.spi
+
+import net.mamoe.mirai.utils.*
+import java.io.IOException
+
+/**
+ * 将源音频文件转换为 silk v3 with tencent 格式
+ *
+ * @since 2.8.0
+ */
+@MiraiExperimentalApi
+public interface AudioToSilkService : BaseService {
+    /**
+     * implementation note:
+     *
+     * 如果返回值为转换后的资源文件:
+     *
+     * 如果 [ExternalResource.isAutoClose], 需要关闭 [source],
+     * 返回的 [ExternalResource] 的 [ExternalResource.isAutoClose] 必须为 `true`
+     *
+     * 特别的, 如果该方法体抛出了一个错误, 如果 [ExternalResource.isAutoClose], 需要关闭 [source]
+     *
+     * @see [withAutoClose]
+     * @see [runAutoClose]
+     * @see [useAutoClose]
+     */
+    @Throws(IOException::class)
+    public suspend fun convert(source: ExternalResource): ExternalResource
+
+    @MiraiExperimentalApi
+    public companion object : AudioToSilkService {
+        private val loader = SPIServiceLoader(object : AudioToSilkService {
+            override suspend fun convert(source: ExternalResource): ExternalResource = source
+        }, AudioToSilkService::class.java)
+
+        @Suppress("BlockingMethodInNonBlockingContext")
+        @Throws(IOException::class)
+        override suspend fun convert(source: ExternalResource): ExternalResource {
+            return loader.service.convert(source)
+        }
+
+        @JvmStatic
+        public fun setService(service: AudioToSilkService) {
+            loader.service = service
+        }
+    }
+}
\ No newline at end of file
diff --git a/mirai-core-api/src/commonMain/kotlin/spi/SPIServiceLoader.kt b/mirai-core-api/src/commonMain/kotlin/spi/SPIServiceLoader.kt
new file mode 100644
index 000000000..ba0e93580
--- /dev/null
+++ b/mirai-core-api/src/commonMain/kotlin/spi/SPIServiceLoader.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.spi
+
+import net.mamoe.mirai.utils.MiraiExperimentalApi
+import net.mamoe.mirai.utils.MiraiLogger
+import java.util.*
+
+/**
+ * 基本 SPI 接口
+ * @since 2.8.0
+ */
+@MiraiExperimentalApi
+public interface BaseService {
+    /** 使用优先级, 值越小越先使用 */
+    public val priority: Int get() = 5
+}
+
+internal class SPIServiceLoader<T : BaseService>(
+    @JvmField val defaultService: T,
+    @JvmField val serviceType: Class<T>,
+) {
+    @JvmField
+    var service: T = defaultService
+
+    fun reload() {
+        val loader = ServiceLoader.load(serviceType)
+        service = loader.minByOrNull { it.priority } ?: defaultService
+    }
+
+    init {
+        reload()
+    }
+
+    companion object {
+        val SPI_SERVICE_LOADER_LOGGER by lazy {
+            MiraiLogger.Factory.create(SPIServiceLoader::class.java, "spi-service-loader")
+        }
+    }
+}
diff --git a/mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt b/mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt
index e6cf146c1..eb74ae44b 100644
--- a/mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt
+++ b/mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt
@@ -133,6 +133,27 @@ public interface ExternalResource : Closeable {
         return generateImageId(md5, formatName.ifEmpty { DEFAULT_FORMAT_NAME })
     }
 
+    /**
+     * 该 [ExternalResource] 的数据来源, 可能有以下的返回
+     *
+     * - [File] 本地文件
+     * - [java.nio.file.Path] 某个具体文件路径
+     * - [java.nio.ByteBuffer] RAM
+     * - [java.net.URI] uri
+     * - [ByteArray] RAM
+     * - Or more...
+     *
+     * implementation note:
+     *
+     * - 对于无法二次读取的数据来源 (如 [InputStream]), 返回 `null`
+     * - 对于一个来自网络的资源, 请返回 [java.net.URI] (not URL, 或者其他库的 URI/URL 类型)
+     * - 不要返回 [String], 没有约定 [String] 代表什么
+     * - 数据源外漏会严重影响 [inputStream] 等的执行的可以返回 `null` (如 [RandomAccessFile])
+     *
+     * @since TODO
+     */
+    public val origin: Any? get() = null
+
     public companion object {
         /**
          * 在无法识别文件格式时使用的默认格式名. "mirai".
@@ -157,7 +178,9 @@ public interface ExternalResource : Closeable {
         @JvmName("create")
         public fun File.toExternalResource(formatName: String? = null): ExternalResource =
             // although RandomAccessFile constructor throws IOException, actual performance influence is minor so not propagating IOException
-            RandomAccessFile(this, "r").toExternalResource(formatName)
+            RandomAccessFile(this, "r").toExternalResource(formatName).also {
+                it.cast<ExternalResourceImplByFile>().origin = this@toExternalResource
+            }
 
         /**
          * 创建 [ExternalResource].
diff --git a/mirai-core/src/commonMain/kotlin/contact/FriendImpl.kt b/mirai-core/src/commonMain/kotlin/contact/FriendImpl.kt
index 34d4509aa..b5c757daf 100644
--- a/mirai-core/src/commonMain/kotlin/contact/FriendImpl.kt
+++ b/mirai-core/src/commonMain/kotlin/contact/FriendImpl.kt
@@ -36,6 +36,7 @@ import net.mamoe.mirai.internal.utils.io.serialization.toByteArray
 import net.mamoe.mirai.message.MessageReceipt
 import net.mamoe.mirai.message.data.Message
 import net.mamoe.mirai.message.data.OfflineAudio
+import net.mamoe.mirai.spi.AudioToSilkService
 import net.mamoe.mirai.utils.*
 import kotlin.contracts.ExperimentalContracts
 import kotlin.contracts.contract
@@ -83,15 +84,17 @@ internal class FriendImpl(
 
     override fun toString(): String = "Friend($id)"
 
-    override suspend fun uploadAudio(resource: ExternalResource): OfflineAudio = resource.withAutoClose {
+    override suspend fun uploadAudio(resource: ExternalResource): OfflineAudio = AudioToSilkService.convert(
+        resource
+    ).useAutoClose { res ->
         var audio: OfflineAudioImpl? = null
         kotlin.runCatching {
             val resp = Highway.uploadResourceBdh(
                 bot = bot,
-                resource = resource,
+                resource = res,
                 kind = ResourceKind.PRIVATE_AUDIO,
                 commandId = 26,
-                extendInfo = PttStore.C2C.createC2CPttStoreBDHExt(bot, this@FriendImpl.uin, resource)
+                extendInfo = PttStore.C2C.createC2CPttStoreBDHExt(bot, this@FriendImpl.uin, res)
                     .toByteArray(Cmd0x346.ReqBody.serializer())
             )
             // resp._miraiContentToString("UV resp")
@@ -100,44 +103,44 @@ internal class FriendImpl(
                 error("Upload failed")
             }
             audio = OfflineAudioImpl(
-                filename = "${resource.md5.toUHexString("")}.amr",
-                fileMd5 = resource.md5,
-                fileSize = resource.size,
-                codec = resource.audioCodec,
+                filename = "${res.md5.toUHexString("")}.amr",
+                fileMd5 = res.md5,
+                fileSize = res.size,
+                codec = res.audioCodec,
                 originalPtt = ImMsgBody.Ptt(
                     fileType = 4,
                     srcUin = bot.uin,
                     fileUuid = c346resp.msgApplyUploadRsp.uuid,
-                    fileMd5 = resource.md5,
-                    fileName = resource.md5 + ".amr".toByteArray(),
-                    fileSize = resource.size.toInt(),
+                    fileMd5 = res.md5,
+                    fileName = res.md5 + ".amr".toByteArray(),
+                    fileSize = res.size.toInt(),
                     boolValid = true,
                 )
             )
         }.recoverCatchingSuppressed {
-            when (val resp = PttStore.GroupPttUp(bot.client, bot.id, id, resource).sendAndExpect(bot)) {
+            when (val resp = PttStore.GroupPttUp(bot.client, bot.id, id, res).sendAndExpect(bot)) {
                 is PttStore.GroupPttUp.Response.RequireUpload -> {
                     tryServersUpload(
                         bot,
                         resp.uploadIpList.zip(resp.uploadPortList),
-                        resource.size,
+                        res.size,
                         ResourceKind.GROUP_AUDIO,
                         ChannelKind.HTTP
                     ) { ip, port ->
-                        Mirai.Http.postPtt(ip, port, resource, resp.uKey, resp.fileKey)
+                        Mirai.Http.postPtt(ip, port, res, resp.uKey, resp.fileKey)
                     }
                     audio = OfflineAudioImpl(
-                        filename = "${resource.md5.toUHexString("")}.amr",
-                        fileMd5 = resource.md5,
-                        fileSize = resource.size,
-                        codec = resource.audioCodec,
+                        filename = "${res.md5.toUHexString("")}.amr",
+                        fileMd5 = res.md5,
+                        fileSize = res.size,
+                        codec = res.audioCodec,
                         originalPtt = ImMsgBody.Ptt(
                             fileType = 4,
                             srcUin = bot.uin,
                             fileUuid = resp.fileId.toByteArray(),
-                            fileMd5 = resource.md5,
-                            fileName = resource.md5 + ".amr".toByteArray(),
-                            fileSize = resource.size.toInt(),
+                            fileMd5 = res.md5,
+                            fileName = res.md5 + ".amr".toByteArray(),
+                            fileSize = res.size.toInt(),
                             boolValid = true,
                         )
                     )
diff --git a/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt b/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt
index a19b0b157..e923d88b1 100644
--- a/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt
+++ b/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt
@@ -47,6 +47,7 @@ import net.mamoe.mirai.internal.utils.io.serialization.toByteArray
 import net.mamoe.mirai.internal.utils.subLogger
 import net.mamoe.mirai.message.MessageReceipt
 import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.spi.AudioToSilkService
 import net.mamoe.mirai.utils.*
 import java.util.concurrent.ConcurrentLinkedQueue
 import kotlin.contracts.contract
@@ -239,19 +240,21 @@ internal class GroupImpl constructor(
     }
 
     @Suppress("OverridingDeprecatedMember", "DEPRECATION")
-    override suspend fun uploadVoice(resource: ExternalResource): Voice = resource.withAutoClose {
+    override suspend fun uploadVoice(resource: ExternalResource): Voice = AudioToSilkService.convert(
+        resource
+    ).useAutoClose { res ->
         return bot.network.run {
-            uploadAudioResource(resource)
+            uploadAudioResource(res)
 
             // val body = resp?.loadAs(Cmd0x388.RspBody.serializer())
             //     ?.msgTryupPttRsp
             //     ?.singleOrNull()?.fileKey ?: error("Group voice highway transfer succeed but failed to find fileKey")
 
             Voice(
-                "${resource.md5.toUHexString("")}.amr",
-                resource.md5,
-                resource.size,
-                resource.voiceCodec,
+                "${res.md5.toUHexString("")}.amr",
+                res.md5,
+                res.size,
+                res.voiceCodec,
                 ""
             )
         }
@@ -284,19 +287,21 @@ internal class GroupImpl constructor(
         }.getOrThrow()
     }
 
-    override suspend fun uploadAudio(resource: ExternalResource): OfflineAudio = resource.withAutoClose {
+    override suspend fun uploadAudio(resource: ExternalResource): OfflineAudio = AudioToSilkService.convert(
+        resource
+    ).useAutoClose { res ->
         return bot.network.run {
-            uploadAudioResource(resource)
+            uploadAudioResource(res)
 
             // val body = resp?.loadAs(Cmd0x388.RspBody.serializer())
             //     ?.msgTryupPttRsp
             //     ?.singleOrNull()?.fileKey ?: error("Group voice highway transfer succeed but failed to find fileKey")
 
             OfflineAudioImpl(
-                filename = "${resource.md5.toUHexString("")}.amr",
-                fileMd5 = resource.md5,
-                fileSize = resource.size,
-                codec = resource.audioCodec,
+                filename = "${res.md5.toUHexString("")}.amr",
+                fileMd5 = res.md5,
+                fileSize = res.size,
+                codec = res.audioCodec,
                 originalPtt = null,
             )
         }