From 8d8aca3f1cfabbd5c8b45c7f740fb6b443da005a Mon Sep 17 00:00:00 2001
From: Him188 <Him188@mamoe.net>
Date: Thu, 5 Aug 2021 12:50:07 +0800
Subject: [PATCH] Introduce `Audio` for new API, revert changes on `Voice`

---
 ...binary-compatibility-validator-android.api | 122 +++++--
 .../api/binary-compatibility-validator.api    | 122 +++++--
 .../commonMain/kotlin/LowLevelApiAccessor.kt  |  16 -
 .../kotlin/contact/AudioSupported.kt          |  45 +++
 .../src/commonMain/kotlin/contact/Friend.kt   |   2 +-
 .../src/commonMain/kotlin/contact/Group.kt    |  14 +-
 .../kotlin/contact/VoiceSupported.kt          |  39 ---
 .../message/MessageSerializersImpl.kt         |  62 ++--
 .../commonMain/kotlin/message/data/Audio.kt   | 274 ++++++++++++++++
 .../commonMain/kotlin/message/data/Voice.kt   | 157 ++++-----
 .../kotlin/utils/ExternalResource.kt          |  56 +---
 .../src/commonMain/kotlin/Bytes.kt            |   2 +-
 mirai-core/src/commonMain/kotlin/MiraiImpl.kt |  18 +-
 .../commonMain/kotlin/contact/FriendImpl.kt   |  72 +++--
 .../commonMain/kotlin/contact/GroupImpl.kt    |  76 +++--
 .../kotlin/message/OnlineAudioImpl.kt         | 302 ++++++++++++++++++
 .../kotlin/message/ReceiveMessageHandler.kt   |  25 +-
 .../kotlin/network/highway/Highway.kt         |   4 +-
 .../kotlin/network/protocol/data/proto/Msg.kt |  64 +++-
 .../chat/receive/MessageSvc.PbSendMsg.kt      |  13 +-
 .../protocol/packet/chat/voice/PttStore.kt    |  11 +-
 ...oe.mirai.message.data.OfflineAudio.Factory |  10 +
 .../commonTest/kotlin/message/AudioTest.kt    |  75 +++++
 .../message/data/MessageSerializationTest.kt  | 112 +++++--
 24 files changed, 1313 insertions(+), 380 deletions(-)
 create mode 100644 mirai-core-api/src/commonMain/kotlin/contact/AudioSupported.kt
 delete mode 100644 mirai-core-api/src/commonMain/kotlin/contact/VoiceSupported.kt
 create mode 100644 mirai-core-api/src/commonMain/kotlin/message/data/Audio.kt
 create mode 100644 mirai-core/src/commonMain/kotlin/message/OnlineAudioImpl.kt
 create mode 100644 mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.message.data.OfflineAudio.Factory
 create mode 100644 mirai-core/src/commonTest/kotlin/message/AudioTest.kt

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 ec33e5759..baacee8d3 100644
--- a/binary-compatibility-validator/android/api/binary-compatibility-validator-android.api
+++ b/binary-compatibility-validator/android/api/binary-compatibility-validator-android.api
@@ -176,6 +176,11 @@ public abstract interface class net/mamoe/mirai/contact/AnonymousMember : net/ma
 	public fun uploadImage (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
+public abstract interface class net/mamoe/mirai/contact/AudioSupported : net/mamoe/mirai/contact/Contact {
+	public fun uploadAudio (Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/OfflineAudio;
+	public abstract fun uploadAudio (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
 public final class net/mamoe/mirai/contact/BotIsBeingMutedException : java/lang/RuntimeException {
 	public fun <init> (Lnet/mamoe/mirai/contact/Group;)V
 	public final fun getTarget ()Lnet/mamoe/mirai/contact/Group;
@@ -326,7 +331,7 @@ public abstract interface class net/mamoe/mirai/contact/FileSupported : net/mamo
 	public abstract fun getFilesRoot ()Lnet/mamoe/mirai/utils/RemoteFile;
 }
 
-public abstract interface class net/mamoe/mirai/contact/Friend : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/User, net/mamoe/mirai/contact/VoiceSupported {
+public abstract interface class net/mamoe/mirai/contact/Friend : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/AudioSupported, net/mamoe/mirai/contact/User {
 	public fun delete ()V
 	public abstract fun delete (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public fun nudge ()Lnet/mamoe/mirai/message/action/FriendNudge;
@@ -338,7 +343,7 @@ public abstract interface class net/mamoe/mirai/contact/Friend : kotlinx/corouti
 	public abstract fun sendMessage (Lnet/mamoe/mirai/message/data/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
-public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/Contact, net/mamoe/mirai/contact/FileSupported, net/mamoe/mirai/contact/VoiceSupported {
+public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/AudioSupported, net/mamoe/mirai/contact/Contact, net/mamoe/mirai/contact/FileSupported {
 	public static final field Companion Lnet/mamoe/mirai/contact/Group$Companion;
 	public abstract fun contains (J)Z
 	public fun contains (Lnet/mamoe/mirai/contact/NormalMember;)Z
@@ -365,6 +370,8 @@ public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutin
 	public fun setEssenceMessage (Lnet/mamoe/mirai/message/data/MessageSource;)Z
 	public abstract fun setEssenceMessage (Lnet/mamoe/mirai/message/data/MessageSource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public abstract fun setName (Ljava/lang/String;)V
+	public fun uploadVoice (Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/Voice;
+	public abstract fun uploadVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
 public final class net/mamoe/mirai/contact/Group$Companion {
@@ -560,11 +567,6 @@ public abstract interface class net/mamoe/mirai/contact/UserOrBot : net/mamoe/mi
 	public abstract fun nudge ()Lnet/mamoe/mirai/message/action/Nudge;
 }
 
-public abstract interface class net/mamoe/mirai/contact/VoiceSupported : net/mamoe/mirai/contact/Contact {
-	public fun uploadVoice (Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/Voice;
-	public abstract fun uploadVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-}
-
 public abstract interface class net/mamoe/mirai/contact/announcement/Announcement {
 	public static final field Companion Lnet/mamoe/mirai/contact/announcement/Announcement$Companion;
 	public abstract fun getContent ()Ljava/lang/String;
@@ -3158,6 +3160,41 @@ public final class net/mamoe/mirai/message/data/AtAll : net/mamoe/mirai/message/
 	public fun toString ()Ljava/lang/String;
 }
 
+public abstract interface class net/mamoe/mirai/message/data/Audio : net/mamoe/mirai/message/data/MessageContent {
+	public static final field Key Lnet/mamoe/mirai/message/data/Audio$Key;
+	public fun contentToString ()Ljava/lang/String;
+	public abstract fun getCodec ()Lnet/mamoe/mirai/message/data/AudioCodec;
+	public abstract fun getExtraData ()[B
+	public abstract fun getFileMd5 ()[B
+	public abstract fun getFileSize ()J
+	public abstract fun getFilename ()Ljava/lang/String;
+	public abstract fun toString ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/message/data/Audio$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+}
+
+public final class net/mamoe/mirai/message/data/AudioCodec : java/lang/Enum {
+	public static final field AMR Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static final field Companion Lnet/mamoe/mirai/message/data/AudioCodec$Companion;
+	public static final field SILK Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static final fun fromFormatName (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static final fun fromFormatNameOrNull (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static final fun fromId (I)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static final fun fromIdOrNull (I)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public final fun getFormatName ()Ljava/lang/String;
+	public final fun getId ()I
+	public static fun valueOf (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static fun values ()[Lnet/mamoe/mirai/message/data/AudioCodec;
+}
+
+public final class net/mamoe/mirai/message/data/AudioCodec$Companion {
+	public final fun fromFormatName (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public final fun fromFormatNameOrNull (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public final fun fromId (I)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public final fun fromIdOrNull (I)Lnet/mamoe/mirai/message/data/AudioCodec;
+}
+
 public abstract interface class net/mamoe/mirai/message/data/ConstrainSingle : net/mamoe/mirai/message/data/SingleMessage {
 	public abstract fun getKey ()Lnet/mamoe/mirai/message/data/MessageKey;
 }
@@ -4499,6 +4536,8 @@ public final class net/mamoe/mirai/message/data/MessageUtils {
 	public static final synthetic fun At (Lnet/mamoe/mirai/contact/UserOrBot;)Lnet/mamoe/mirai/message/data/At;
 	public static final synthetic fun FileMessage (Ljava/lang/String;ILjava/lang/String;J)Lnet/mamoe/mirai/message/data/FileMessage;
 	public static final synthetic fun Image (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/Image;
+	public static final synthetic fun OfflineAudio (Ljava/lang/String;[BJLnet/mamoe/mirai/message/data/AudioCodec;[B)Lnet/mamoe/mirai/message/data/OfflineAudio;
+	public static final synthetic fun OfflineAudio (Lnet/mamoe/mirai/message/data/OnlineAudio;)Lnet/mamoe/mirai/message/data/OfflineAudio;
 	public static final synthetic fun UnsupportedMessage ([B)Lnet/mamoe/mirai/message/data/UnsupportedMessage;
 	public static final synthetic fun at (Lnet/mamoe/mirai/contact/Member;)Lnet/mamoe/mirai/message/data/At;
 	public static final fun buildMessageChain (ILkotlin/jvm/functions/Function1;)Lnet/mamoe/mirai/message/data/MessageChain;
@@ -4541,6 +4580,7 @@ public final class net/mamoe/mirai/message/data/MessageUtils {
 	public static final synthetic fun toMessageChain ([Lnet/mamoe/mirai/message/data/Message;)Lnet/mamoe/mirai/message/data/MessageChain;
 	public static final fun toOfflineMessageSource (Lnet/mamoe/mirai/message/data/OnlineMessageSource;)Lnet/mamoe/mirai/message/data/OfflineMessageSource;
 	public static final synthetic fun toPlainText (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/PlainText;
+	public static final synthetic fun toVoice (Lnet/mamoe/mirai/message/data/Audio;)Lnet/mamoe/mirai/message/data/Voice;
 }
 
 public final class net/mamoe/mirai/message/data/MusicKind : java/lang/Enum {
@@ -4604,6 +4644,26 @@ public final class net/mamoe/mirai/message/data/MusicShare$Key : net/mamoe/mirai
 	public final fun serializer ()Lkotlinx/serialization/KSerializer;
 }
 
+public abstract interface class net/mamoe/mirai/message/data/OfflineAudio : net/mamoe/mirai/message/data/Audio {
+	public static final field Key Lnet/mamoe/mirai/message/data/OfflineAudio$Key;
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
+public abstract interface class net/mamoe/mirai/message/data/OfflineAudio$Factory {
+	public static final field INSTANCE Lnet/mamoe/mirai/message/data/OfflineAudio$Factory$INSTANCE;
+	public abstract fun create (Ljava/lang/String;[BJLnet/mamoe/mirai/message/data/AudioCodec;[B)Lnet/mamoe/mirai/message/data/OfflineAudio;
+	public fun from (Lnet/mamoe/mirai/message/data/OnlineAudio;)Lnet/mamoe/mirai/message/data/OfflineAudio;
+}
+
+public final class net/mamoe/mirai/message/data/OfflineAudio$Factory$INSTANCE : net/mamoe/mirai/message/data/OfflineAudio$Factory {
+	public fun create (Ljava/lang/String;[BJLnet/mamoe/mirai/message/data/AudioCodec;[B)Lnet/mamoe/mirai/message/data/OfflineAudio;
+	public fun from (Lnet/mamoe/mirai/message/data/OnlineAudio;)Lnet/mamoe/mirai/message/data/OfflineAudio;
+}
+
+public final class net/mamoe/mirai/message/data/OfflineAudio$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
 public abstract class net/mamoe/mirai/message/data/OfflineMessageSource : net/mamoe/mirai/message/data/MessageSource {
 	public static final field Key Lnet/mamoe/mirai/message/data/OfflineMessageSource$Key;
 	public fun <init> ()V
@@ -4613,6 +4673,17 @@ public abstract class net/mamoe/mirai/message/data/OfflineMessageSource : net/ma
 public final class net/mamoe/mirai/message/data/OfflineMessageSource$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
 }
 
+public abstract interface class net/mamoe/mirai/message/data/OnlineAudio : net/mamoe/mirai/message/data/Audio {
+	public static final field Key Lnet/mamoe/mirai/message/data/OnlineAudio$Key;
+	public static final field SERIAL_NAME Ljava/lang/String;
+	public abstract fun getLength ()J
+	public abstract fun getUrlForDownload ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/message/data/OnlineAudio$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
 public abstract class net/mamoe/mirai/message/data/OnlineMessageSource : net/mamoe/mirai/message/data/MessageSource {
 	public static final field Key Lnet/mamoe/mirai/message/data/OnlineMessageSource$Key;
 	public abstract fun getBot ()Lnet/mamoe/mirai/Bot;
@@ -5090,32 +5161,39 @@ public final class net/mamoe/mirai/message/data/VipFace$Kind$Companion {
 	public final fun serializer ()Lkotlinx/serialization/KSerializer;
 }
 
-public final class net/mamoe/mirai/message/data/Voice : net/mamoe/mirai/message/data/PttMessage {
+public class net/mamoe/mirai/message/data/Voice : net/mamoe/mirai/message/data/PttMessage {
 	public static final field Key Lnet/mamoe/mirai/message/data/Voice$Key;
 	public static final field SERIAL_NAME Ljava/lang/String;
+	public synthetic fun <init> (ILjava/lang/String;[BJILjava/lang/String;Ljava/lang/String;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V
 	public synthetic fun <init> (Ljava/lang/String;[BJILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
 	public fun contentToString ()Ljava/lang/String;
 	public fun equals (Ljava/lang/Object;)Z
-	public final fun getCodec ()I
+	public static final fun fromAudio (Lnet/mamoe/mirai/message/data/Audio;)Lnet/mamoe/mirai/message/data/Voice;
 	public fun getFileName ()Ljava/lang/String;
 	public fun getFileSize ()J
 	public fun getMd5 ()[B
-	public final fun getUrl ()Ljava/lang/String;
+	public fun getUrl ()Ljava/lang/String;
+	public final fun get_codec ()I
 	public fun hashCode ()I
+	public final fun toAudio ()Lnet/mamoe/mirai/message/data/Audio;
 	public fun toString ()Ljava/lang/String;
 }
 
-public final class net/mamoe/mirai/message/data/Voice$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
-	public final fun serializer ()Lkotlinx/serialization/KSerializer;
-}
-
-public final class net/mamoe/mirai/message/data/Voice$Serializer : kotlinx/serialization/KSerializer {
-	public static final field INSTANCE Lnet/mamoe/mirai/message/data/Voice$Serializer;
+public final class net/mamoe/mirai/message/data/Voice$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
+	public static final field INSTANCE Lnet/mamoe/mirai/message/data/Voice$$serializer;
+	public static final synthetic field descriptor Lkotlinx/serialization/descriptors/SerialDescriptor;
+	public fun childSerializers ()[Lkotlinx/serialization/KSerializer;
 	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
 	public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lnet/mamoe/mirai/message/data/Voice;
 	public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
 	public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
 	public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lnet/mamoe/mirai/message/data/Voice;)V
+	public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
+}
+
+public final class net/mamoe/mirai/message/data/Voice$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public final fun fromAudio (Lnet/mamoe/mirai/message/data/Audio;)Lnet/mamoe/mirai/message/data/Voice;
+	public final fun serializer ()Lkotlinx/serialization/KSerializer;
 }
 
 public final class net/mamoe/mirai/message/data/XmlMessageBuilder$ItemBuilder {
@@ -5429,14 +5507,8 @@ public abstract interface class net/mamoe/mirai/utils/ExternalResource : java/io
 	public static fun uploadAsImage (Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun uploadAsImage (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Image;
 	public static fun uploadAsImage (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-	public static fun uploadAsVoice (Ljava/io/File;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public static fun uploadAsVoice (Ljava/io/File;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-	public static fun uploadAsVoice (Ljava/io/InputStream;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public static fun uploadAsVoice (Ljava/io/InputStream;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Voice;
 	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
 	public static fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;)Lnet/mamoe/mirai/message/data/FileMessage;
@@ -5504,14 +5576,8 @@ public final class net/mamoe/mirai/utils/ExternalResource$Companion {
 	public static synthetic fun uploadAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
 	public static synthetic fun uploadAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;ILjava/lang/Object;)Lnet/mamoe/mirai/message/data/Image;
 	public static synthetic fun uploadAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
-	public final fun uploadAsVoice (Ljava/io/File;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public final fun uploadAsVoice (Ljava/io/File;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-	public final fun uploadAsVoice (Ljava/io/InputStream;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public final fun uploadAsVoice (Ljava/io/InputStream;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Voice;
 	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
 	public final fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;)Lnet/mamoe/mirai/message/data/FileMessage;
diff --git a/binary-compatibility-validator/api/binary-compatibility-validator.api b/binary-compatibility-validator/api/binary-compatibility-validator.api
index 992a66c04..01f874110 100644
--- a/binary-compatibility-validator/api/binary-compatibility-validator.api
+++ b/binary-compatibility-validator/api/binary-compatibility-validator.api
@@ -176,6 +176,11 @@ public abstract interface class net/mamoe/mirai/contact/AnonymousMember : net/ma
 	public fun uploadImage (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
+public abstract interface class net/mamoe/mirai/contact/AudioSupported : net/mamoe/mirai/contact/Contact {
+	public fun uploadAudio (Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/OfflineAudio;
+	public abstract fun uploadAudio (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
 public final class net/mamoe/mirai/contact/BotIsBeingMutedException : java/lang/RuntimeException {
 	public fun <init> (Lnet/mamoe/mirai/contact/Group;)V
 	public final fun getTarget ()Lnet/mamoe/mirai/contact/Group;
@@ -326,7 +331,7 @@ public abstract interface class net/mamoe/mirai/contact/FileSupported : net/mamo
 	public abstract fun getFilesRoot ()Lnet/mamoe/mirai/utils/RemoteFile;
 }
 
-public abstract interface class net/mamoe/mirai/contact/Friend : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/User, net/mamoe/mirai/contact/VoiceSupported {
+public abstract interface class net/mamoe/mirai/contact/Friend : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/AudioSupported, net/mamoe/mirai/contact/User {
 	public fun delete ()V
 	public abstract fun delete (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public fun nudge ()Lnet/mamoe/mirai/message/action/FriendNudge;
@@ -338,7 +343,7 @@ public abstract interface class net/mamoe/mirai/contact/Friend : kotlinx/corouti
 	public abstract fun sendMessage (Lnet/mamoe/mirai/message/data/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
-public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/Contact, net/mamoe/mirai/contact/FileSupported, net/mamoe/mirai/contact/VoiceSupported {
+public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/AudioSupported, net/mamoe/mirai/contact/Contact, net/mamoe/mirai/contact/FileSupported {
 	public static final field Companion Lnet/mamoe/mirai/contact/Group$Companion;
 	public abstract fun contains (J)Z
 	public fun contains (Lnet/mamoe/mirai/contact/NormalMember;)Z
@@ -365,6 +370,8 @@ public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutin
 	public fun setEssenceMessage (Lnet/mamoe/mirai/message/data/MessageSource;)Z
 	public abstract fun setEssenceMessage (Lnet/mamoe/mirai/message/data/MessageSource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public abstract fun setName (Ljava/lang/String;)V
+	public fun uploadVoice (Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/Voice;
+	public abstract fun uploadVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
 public final class net/mamoe/mirai/contact/Group$Companion {
@@ -560,11 +567,6 @@ public abstract interface class net/mamoe/mirai/contact/UserOrBot : net/mamoe/mi
 	public abstract fun nudge ()Lnet/mamoe/mirai/message/action/Nudge;
 }
 
-public abstract interface class net/mamoe/mirai/contact/VoiceSupported : net/mamoe/mirai/contact/Contact {
-	public fun uploadVoice (Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/Voice;
-	public abstract fun uploadVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-}
-
 public abstract interface class net/mamoe/mirai/contact/announcement/Announcement {
 	public static final field Companion Lnet/mamoe/mirai/contact/announcement/Announcement$Companion;
 	public abstract fun getContent ()Ljava/lang/String;
@@ -3158,6 +3160,41 @@ public final class net/mamoe/mirai/message/data/AtAll : net/mamoe/mirai/message/
 	public fun toString ()Ljava/lang/String;
 }
 
+public abstract interface class net/mamoe/mirai/message/data/Audio : net/mamoe/mirai/message/data/MessageContent {
+	public static final field Key Lnet/mamoe/mirai/message/data/Audio$Key;
+	public fun contentToString ()Ljava/lang/String;
+	public abstract fun getCodec ()Lnet/mamoe/mirai/message/data/AudioCodec;
+	public abstract fun getExtraData ()[B
+	public abstract fun getFileMd5 ()[B
+	public abstract fun getFileSize ()J
+	public abstract fun getFilename ()Ljava/lang/String;
+	public abstract fun toString ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/message/data/Audio$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+}
+
+public final class net/mamoe/mirai/message/data/AudioCodec : java/lang/Enum {
+	public static final field AMR Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static final field Companion Lnet/mamoe/mirai/message/data/AudioCodec$Companion;
+	public static final field SILK Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static final fun fromFormatName (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static final fun fromFormatNameOrNull (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static final fun fromId (I)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static final fun fromIdOrNull (I)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public final fun getFormatName ()Ljava/lang/String;
+	public final fun getId ()I
+	public static fun valueOf (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public static fun values ()[Lnet/mamoe/mirai/message/data/AudioCodec;
+}
+
+public final class net/mamoe/mirai/message/data/AudioCodec$Companion {
+	public final fun fromFormatName (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public final fun fromFormatNameOrNull (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public final fun fromId (I)Lnet/mamoe/mirai/message/data/AudioCodec;
+	public final fun fromIdOrNull (I)Lnet/mamoe/mirai/message/data/AudioCodec;
+}
+
 public abstract interface class net/mamoe/mirai/message/data/ConstrainSingle : net/mamoe/mirai/message/data/SingleMessage {
 	public abstract fun getKey ()Lnet/mamoe/mirai/message/data/MessageKey;
 }
@@ -4499,6 +4536,8 @@ public final class net/mamoe/mirai/message/data/MessageUtils {
 	public static final synthetic fun At (Lnet/mamoe/mirai/contact/UserOrBot;)Lnet/mamoe/mirai/message/data/At;
 	public static final synthetic fun FileMessage (Ljava/lang/String;ILjava/lang/String;J)Lnet/mamoe/mirai/message/data/FileMessage;
 	public static final synthetic fun Image (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/Image;
+	public static final synthetic fun OfflineAudio (Ljava/lang/String;[BJLnet/mamoe/mirai/message/data/AudioCodec;[B)Lnet/mamoe/mirai/message/data/OfflineAudio;
+	public static final synthetic fun OfflineAudio (Lnet/mamoe/mirai/message/data/OnlineAudio;)Lnet/mamoe/mirai/message/data/OfflineAudio;
 	public static final synthetic fun UnsupportedMessage ([B)Lnet/mamoe/mirai/message/data/UnsupportedMessage;
 	public static final synthetic fun at (Lnet/mamoe/mirai/contact/Member;)Lnet/mamoe/mirai/message/data/At;
 	public static final fun buildMessageChain (ILkotlin/jvm/functions/Function1;)Lnet/mamoe/mirai/message/data/MessageChain;
@@ -4541,6 +4580,7 @@ public final class net/mamoe/mirai/message/data/MessageUtils {
 	public static final synthetic fun toMessageChain ([Lnet/mamoe/mirai/message/data/Message;)Lnet/mamoe/mirai/message/data/MessageChain;
 	public static final fun toOfflineMessageSource (Lnet/mamoe/mirai/message/data/OnlineMessageSource;)Lnet/mamoe/mirai/message/data/OfflineMessageSource;
 	public static final synthetic fun toPlainText (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/PlainText;
+	public static final synthetic fun toVoice (Lnet/mamoe/mirai/message/data/Audio;)Lnet/mamoe/mirai/message/data/Voice;
 }
 
 public final class net/mamoe/mirai/message/data/MusicKind : java/lang/Enum {
@@ -4604,6 +4644,26 @@ public final class net/mamoe/mirai/message/data/MusicShare$Key : net/mamoe/mirai
 	public final fun serializer ()Lkotlinx/serialization/KSerializer;
 }
 
+public abstract interface class net/mamoe/mirai/message/data/OfflineAudio : net/mamoe/mirai/message/data/Audio {
+	public static final field Key Lnet/mamoe/mirai/message/data/OfflineAudio$Key;
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
+public abstract interface class net/mamoe/mirai/message/data/OfflineAudio$Factory {
+	public static final field INSTANCE Lnet/mamoe/mirai/message/data/OfflineAudio$Factory$INSTANCE;
+	public abstract fun create (Ljava/lang/String;[BJLnet/mamoe/mirai/message/data/AudioCodec;[B)Lnet/mamoe/mirai/message/data/OfflineAudio;
+	public fun from (Lnet/mamoe/mirai/message/data/OnlineAudio;)Lnet/mamoe/mirai/message/data/OfflineAudio;
+}
+
+public final class net/mamoe/mirai/message/data/OfflineAudio$Factory$INSTANCE : net/mamoe/mirai/message/data/OfflineAudio$Factory {
+	public fun create (Ljava/lang/String;[BJLnet/mamoe/mirai/message/data/AudioCodec;[B)Lnet/mamoe/mirai/message/data/OfflineAudio;
+	public fun from (Lnet/mamoe/mirai/message/data/OnlineAudio;)Lnet/mamoe/mirai/message/data/OfflineAudio;
+}
+
+public final class net/mamoe/mirai/message/data/OfflineAudio$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
 public abstract class net/mamoe/mirai/message/data/OfflineMessageSource : net/mamoe/mirai/message/data/MessageSource {
 	public static final field Key Lnet/mamoe/mirai/message/data/OfflineMessageSource$Key;
 	public fun <init> ()V
@@ -4613,6 +4673,17 @@ public abstract class net/mamoe/mirai/message/data/OfflineMessageSource : net/ma
 public final class net/mamoe/mirai/message/data/OfflineMessageSource$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
 }
 
+public abstract interface class net/mamoe/mirai/message/data/OnlineAudio : net/mamoe/mirai/message/data/Audio {
+	public static final field Key Lnet/mamoe/mirai/message/data/OnlineAudio$Key;
+	public static final field SERIAL_NAME Ljava/lang/String;
+	public abstract fun getLength ()J
+	public abstract fun getUrlForDownload ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/message/data/OnlineAudio$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
 public abstract class net/mamoe/mirai/message/data/OnlineMessageSource : net/mamoe/mirai/message/data/MessageSource {
 	public static final field Key Lnet/mamoe/mirai/message/data/OnlineMessageSource$Key;
 	public abstract fun getBot ()Lnet/mamoe/mirai/Bot;
@@ -5090,32 +5161,39 @@ public final class net/mamoe/mirai/message/data/VipFace$Kind$Companion {
 	public final fun serializer ()Lkotlinx/serialization/KSerializer;
 }
 
-public final class net/mamoe/mirai/message/data/Voice : net/mamoe/mirai/message/data/PttMessage {
+public class net/mamoe/mirai/message/data/Voice : net/mamoe/mirai/message/data/PttMessage {
 	public static final field Key Lnet/mamoe/mirai/message/data/Voice$Key;
 	public static final field SERIAL_NAME Ljava/lang/String;
+	public synthetic fun <init> (ILjava/lang/String;[BJILjava/lang/String;Ljava/lang/String;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V
 	public synthetic fun <init> (Ljava/lang/String;[BJILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
 	public fun contentToString ()Ljava/lang/String;
 	public fun equals (Ljava/lang/Object;)Z
-	public final fun getCodec ()I
+	public static final fun fromAudio (Lnet/mamoe/mirai/message/data/Audio;)Lnet/mamoe/mirai/message/data/Voice;
 	public fun getFileName ()Ljava/lang/String;
 	public fun getFileSize ()J
 	public fun getMd5 ()[B
-	public final fun getUrl ()Ljava/lang/String;
+	public fun getUrl ()Ljava/lang/String;
+	public final fun get_codec ()I
 	public fun hashCode ()I
+	public final fun toAudio ()Lnet/mamoe/mirai/message/data/Audio;
 	public fun toString ()Ljava/lang/String;
 }
 
-public final class net/mamoe/mirai/message/data/Voice$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
-	public final fun serializer ()Lkotlinx/serialization/KSerializer;
-}
-
-public final class net/mamoe/mirai/message/data/Voice$Serializer : kotlinx/serialization/KSerializer {
-	public static final field INSTANCE Lnet/mamoe/mirai/message/data/Voice$Serializer;
+public final class net/mamoe/mirai/message/data/Voice$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
+	public static final field INSTANCE Lnet/mamoe/mirai/message/data/Voice$$serializer;
+	public static final synthetic field descriptor Lkotlinx/serialization/descriptors/SerialDescriptor;
+	public fun childSerializers ()[Lkotlinx/serialization/KSerializer;
 	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
 	public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lnet/mamoe/mirai/message/data/Voice;
 	public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
 	public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
 	public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lnet/mamoe/mirai/message/data/Voice;)V
+	public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
+}
+
+public final class net/mamoe/mirai/message/data/Voice$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public final fun fromAudio (Lnet/mamoe/mirai/message/data/Audio;)Lnet/mamoe/mirai/message/data/Voice;
+	public final fun serializer ()Lkotlinx/serialization/KSerializer;
 }
 
 public final class net/mamoe/mirai/message/data/XmlMessageBuilder$ItemBuilder {
@@ -5429,14 +5507,8 @@ public abstract interface class net/mamoe/mirai/utils/ExternalResource : java/io
 	public static fun uploadAsImage (Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun uploadAsImage (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Image;
 	public static fun uploadAsImage (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-	public static fun uploadAsVoice (Ljava/io/File;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public static fun uploadAsVoice (Ljava/io/File;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-	public static fun uploadAsVoice (Ljava/io/InputStream;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public static fun uploadAsVoice (Ljava/io/InputStream;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Voice;
 	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
 	public static fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;)Lnet/mamoe/mirai/message/data/FileMessage;
@@ -5504,14 +5576,8 @@ public final class net/mamoe/mirai/utils/ExternalResource$Companion {
 	public static synthetic fun uploadAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
 	public static synthetic fun uploadAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;ILjava/lang/Object;)Lnet/mamoe/mirai/message/data/Image;
 	public static synthetic fun uploadAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
-	public final fun uploadAsVoice (Ljava/io/File;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public final fun uploadAsVoice (Ljava/io/File;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-	public final fun uploadAsVoice (Ljava/io/InputStream;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public final fun uploadAsVoice (Ljava/io/InputStream;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Voice;
 	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/VoiceSupported;)Lnet/mamoe/mirai/message/data/Voice;
-	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/VoiceSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
 	public final fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;)Lnet/mamoe/mirai/message/data/FileMessage;
diff --git a/mirai-core-api/src/commonMain/kotlin/LowLevelApiAccessor.kt b/mirai-core-api/src/commonMain/kotlin/LowLevelApiAccessor.kt
index aee96bace..0f344257d 100644
--- a/mirai-core-api/src/commonMain/kotlin/LowLevelApiAccessor.kt
+++ b/mirai-core-api/src/commonMain/kotlin/LowLevelApiAccessor.kt
@@ -15,9 +15,7 @@ import kotlinx.coroutines.Job
 import net.mamoe.kjbb.JvmBlockingBridge
 import net.mamoe.mirai.contact.*
 import net.mamoe.mirai.data.*
-import net.mamoe.mirai.message.data.Voice
 import net.mamoe.mirai.utils.MiraiExperimentalApi
-import net.mamoe.mirai.utils.MiraiInternalApi
 import net.mamoe.mirai.utils.NotStableForInheritance
 import net.mamoe.mirai.utils.WeakRef
 import kotlin.annotation.AnnotationTarget.*
@@ -219,18 +217,4 @@ public interface LowLevelApiAccessor {
         groupId: Long,
         seconds: Int,
     )
-
-    /**
-     * 序列化 [Voice.pttInternalInstance]
-     */
-    @LowLevelApi
-    @MiraiInternalApi // For Voice serialize
-    public fun serializePttElem(ptt: Any?): String
-
-    /**
-     * 反序列化 [Voice.pttInternalInstance]
-     */
-    @LowLevelApi
-    @MiraiInternalApi // For Voice serialize
-    public fun deserializePttElem(ptt: String): Any?
 }
diff --git a/mirai-core-api/src/commonMain/kotlin/contact/AudioSupported.kt b/mirai-core-api/src/commonMain/kotlin/contact/AudioSupported.kt
new file mode 100644
index 000000000..c6c9eadd9
--- /dev/null
+++ b/mirai-core-api/src/commonMain/kotlin/contact/AudioSupported.kt
@@ -0,0 +1,45 @@
+/*
+ * 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
+ */
+
+@file:JvmBlockingBridge
+
+package net.mamoe.mirai.contact
+
+import net.mamoe.kjbb.JvmBlockingBridge
+import net.mamoe.mirai.message.data.Audio
+import net.mamoe.mirai.message.data.OfflineAudio
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.NotStableForInheritance
+import net.mamoe.mirai.utils.OverFileSizeMaxException
+
+/**
+ * 支持发送语音的 [Contact]
+ *
+ * @since 2.7
+ */
+@NotStableForInheritance
+public interface AudioSupported : Contact {
+    /**
+     * 上传一个语音文件以备发送. [resource] 需要调用方[关闭][ExternalResource.close].
+     *
+     * 多次调用 [uploadAudio] 使用同一个 [resource] 时, 将会发生多次上传, 且有可能产生不同的 [OfflineAudio] 对象, 因为服务器不会提供有关文件是否已经存在于服务器的信息.
+     *
+     * 返回的 [OfflineAudio] 支持序列化, 可以保存后在将来使用, 而不需要立即[发送][Contact.sendMessage]. 但不建议保存太久, 无法确定服务器保留一个文件的时间.
+     *
+     * 建议使用同一个 [Contact] 进行 [uploadAudio] 和 [sendMessage]. 目标对象不同时的行为是不确定的.
+     *
+     * 要获取更多语音相关的信息, 参阅 [Audio].
+     *
+     * @throws OverFileSizeMaxException 当语音文件过大而被服务器拒绝上传时. (最大大小约为 1 MB)
+     * **注意**: 由于服务器不一定会检查大小, 该异常就不一定会因大小超过 1MB 而抛出.
+     *
+     * @since 2.7
+     */
+    public suspend fun uploadAudio(resource: ExternalResource): OfflineAudio
+}
diff --git a/mirai-core-api/src/commonMain/kotlin/contact/Friend.kt b/mirai-core-api/src/commonMain/kotlin/contact/Friend.kt
index 5014c0340..bbb6486ba 100644
--- a/mirai-core-api/src/commonMain/kotlin/contact/Friend.kt
+++ b/mirai-core-api/src/commonMain/kotlin/contact/Friend.kt
@@ -34,7 +34,7 @@ import net.mamoe.mirai.utils.NotStableForInheritance
  * @see FriendMessageEvent
  */
 @NotStableForInheritance
-public interface Friend : User, CoroutineScope, VoiceSupported {
+public interface Friend : User, CoroutineScope, AudioSupported {
     /**
      * 向这个对象发送消息.
      *
diff --git a/mirai-core-api/src/commonMain/kotlin/contact/Group.kt b/mirai-core-api/src/commonMain/kotlin/contact/Group.kt
index b539ab810..7de30e23b 100644
--- a/mirai-core-api/src/commonMain/kotlin/contact/Group.kt
+++ b/mirai-core-api/src/commonMain/kotlin/contact/Group.kt
@@ -19,6 +19,7 @@ import net.mamoe.mirai.contact.announcement.Announcements
 import net.mamoe.mirai.event.events.*
 import net.mamoe.mirai.message.MessageReceipt
 import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.utils.ExternalResource
 import net.mamoe.mirai.utils.MiraiExperimentalApi
 import net.mamoe.mirai.utils.NotStableForInheritance
 
@@ -52,7 +53,7 @@ import net.mamoe.mirai.utils.NotStableForInheritance
  * ##
  */
 @NotStableForInheritance
-public interface Group : Contact, CoroutineScope, FileSupported, VoiceSupported {
+public interface Group : Contact, CoroutineScope, FileSupported, AudioSupported {
     /**
      * 群名称.
      *
@@ -184,6 +185,17 @@ public interface Group : Contact, CoroutineScope, FileSupported, VoiceSupported
         this.sendMessage(message.toPlainText())
 
 
+    /**
+     * 上传一个语音消息以备发送. 该方法已弃用且将在未来版本删除, 请使用 [uploadAudio].
+     */
+    @Suppress("DEPRECATION")
+    @Deprecated(
+        "use uploadAudio",
+        replaceWith = ReplaceWith("uploadAudio(resource)"),
+        level = DeprecationLevel.WARNING
+    )
+    public suspend fun uploadVoice(resource: ExternalResource): Voice
+
     /**
      * 将一条消息设置为群精华消息, 需要管理员或群主权限.
      * 操作成功返回 `true`.
diff --git a/mirai-core-api/src/commonMain/kotlin/contact/VoiceSupported.kt b/mirai-core-api/src/commonMain/kotlin/contact/VoiceSupported.kt
deleted file mode 100644
index 5db799a70..000000000
--- a/mirai-core-api/src/commonMain/kotlin/contact/VoiceSupported.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * 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.contact
-
-import net.mamoe.kjbb.JvmBlockingBridge
-import net.mamoe.mirai.message.data.Voice
-import net.mamoe.mirai.utils.ExternalResource
-import net.mamoe.mirai.utils.ExternalResource.Companion.uploadAsVoice
-import net.mamoe.mirai.utils.NotStableForInheritance
-import net.mamoe.mirai.utils.OverFileSizeMaxException
-
-/**
- * 支持发送语音的 [Contact]
- *
- * @since 2.7
- */
-@NotStableForInheritance
-public interface VoiceSupported : Contact {
-    /**
-     * 上传一个语音消息以备发送.
-     *
-     * - **请手动关闭 [resource]**
-     * - 请使用 amr 或 silk 格式
-     *
-     * @since 2.7
-     * @see ExternalResource.uploadAsVoice
-     * @throws OverFileSizeMaxException 当语音文件过大而被服务器拒绝上传时. (最大大小约为 1 MB)
-     */
-    @JvmBlockingBridge
-    public suspend fun uploadVoice(resource: ExternalResource): Voice
-
-}
diff --git a/mirai-core-api/src/commonMain/kotlin/internal/message/MessageSerializersImpl.kt b/mirai-core-api/src/commonMain/kotlin/internal/message/MessageSerializersImpl.kt
index 232512fdf..0cce9fa96 100644
--- a/mirai-core-api/src/commonMain/kotlin/internal/message/MessageSerializersImpl.kt
+++ b/mirai-core-api/src/commonMain/kotlin/internal/message/MessageSerializersImpl.kt
@@ -13,14 +13,12 @@ import kotlinx.serialization.KSerializer
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 import kotlinx.serialization.descriptors.buildClassSerialDescriptor
-import kotlinx.serialization.modules.PolymorphicModuleBuilder
-import kotlinx.serialization.modules.SerializersModule
-import kotlinx.serialization.modules.overwriteWith
-import kotlinx.serialization.modules.polymorphic
+import kotlinx.serialization.modules.*
 import net.mamoe.mirai.Mirai
 import net.mamoe.mirai.message.MessageSerializers
 import net.mamoe.mirai.message.data.*
 import net.mamoe.mirai.utils.MiraiInternalApi
+import net.mamoe.mirai.utils.lateinitMutableProperty
 import net.mamoe.mirai.utils.map
 import net.mamoe.mirai.utils.takeElementsFrom
 import kotlin.reflect.KClass
@@ -121,6 +119,7 @@ private val builtInSerializersModule by lazy {
             subclass(SimpleServiceMessage::class, SimpleServiceMessage.serializer())
 
             //  subclass(PttMessage::class, PttMessage.serializer())
+            @Suppress("DEPRECATION")
             subclass(Voice::class, Voice.serializer())
 
             // subclass(HummerMessage::class, HummerMessage.serializer())
@@ -188,31 +187,58 @@ private val builtInSerializersModule by lazy {
 // Tests:
 // net.mamoe.mirai.internal.message.data.MessageSerializationTest
 internal object MessageSerializersImpl : MessageSerializers {
-    @Volatile
-    private var serializersModuleField: SerializersModule? = null
+    private var serializersModuleField: SerializersModule by lateinitMutableProperty {
+        builtInSerializersModule
+    }
+
     override val serializersModule: SerializersModule
         get() {
             Mirai // ensure registered, for tests
-            return serializersModuleField ?: builtInSerializersModule
+            return serializersModuleField
         }
 
     @Synchronized
     override fun <M : SingleMessage> registerSerializer(type: KClass<M>, serializer: KSerializer<M>) {
-        serializersModuleField = serializersModule.overwriteWith(SerializersModule {
-            // contextual(type, serializer)
-            for (superclass in type.allSuperclasses) {
-                if (superclass.isFinal) continue
-                if (!superclass.isSubclassOf(SingleMessage::class)) continue
-                @Suppress("UNCHECKED_CAST")
-                polymorphic(superclass as KClass<Any>) {
-                    subclass(type, serializer)
-                }
-            }
-        })
+        serializersModuleField = serializersModule.overwritePolymorphicWith(type, serializer)
     }
 
     @Synchronized
     override fun registerSerializers(serializersModule: SerializersModule) {
         serializersModuleField = serializersModule.overwriteWith(serializersModule)
     }
+}
+
+internal fun <M : Any> SerializersModule.overwritePolymorphicWith(
+    type: KClass<M>,
+    serializer: KSerializer<M>
+): SerializersModule {
+    return overwriteWith(SerializersModule {
+        // contextual(type, serializer)
+        for (superclass in type.allSuperclasses) {
+            if (superclass.isFinal) continue
+            if (!superclass.isSubclassOf(SingleMessage::class)) continue
+            @Suppress("UNCHECKED_CAST")
+            polymorphic(superclass as KClass<Any>) {
+                subclass(type, serializer)
+            }
+        }
+    })
+}
+
+private inline fun <reified M : SingleMessage> SerializersModuleBuilder.hierarchicallyPolymorphic(serializer: KSerializer<M>) =
+    hierarchicallyPolymorphic(M::class, serializer)
+
+private fun <M : SingleMessage> SerializersModuleBuilder.hierarchicallyPolymorphic(
+    type: KClass<M>,
+    serializer: KSerializer<M>
+) {
+    // contextual(type, serializer)
+    for (superclass in type.allSuperclasses) {
+        if (superclass.isFinal) continue
+        if (!superclass.isSubclassOf(SingleMessage::class)) continue
+        @Suppress("UNCHECKED_CAST")
+        polymorphic(superclass as KClass<Any>) {
+            subclass(type, serializer)
+        }
+    }
 }
\ No newline at end of file
diff --git a/mirai-core-api/src/commonMain/kotlin/message/data/Audio.kt b/mirai-core-api/src/commonMain/kotlin/message/data/Audio.kt
new file mode 100644
index 000000000..e71e6543e
--- /dev/null
+++ b/mirai-core-api/src/commonMain/kotlin/message/data/Audio.kt
@@ -0,0 +1,274 @@
+/*
+ * 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
+ */
+
+@file:Suppress("NOTHING_TO_INLINE", "unused")
+@file:JvmMultifileClass
+@file:JvmName("MessageUtils")
+
+package net.mamoe.mirai.message.data
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.builtins.serializer
+import net.mamoe.mirai.contact.AudioSupported
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.message.MessageSerializers
+import net.mamoe.mirai.message.data.MessageChain.Companion.serializeToJsonString
+import net.mamoe.mirai.utils.*
+
+/**
+ * 语音消息.
+ *
+ * [Audio] 分为 [OnlineAudio] 与 [OfflineAudio]. 在本地上传的, 或手动构造的语音为 [OfflineAudio]. 从服务器接收的语音为 [OnlineAudio].
+ *
+ * ## 上传和发送语音
+ *
+ * 使用 [AudioSupported.uploadAudio] 上传语音到服务器并取得 [Audio] 消息实例, 然后通过 [Contact.sendMessage] 发送.
+ *
+ * Java 示例:
+ * ```
+ * Audio audio;
+ * try {
+ *     audio = group.uploadAudio(resource); // 上传文件得到语音实例
+ * } finally {
+ *     resource.close(); // 保证资源正常关闭
+ * }
+ * group.sendMessage(audio); // 发送语音消息
+ * ```
+ *
+ * ## 下载语音
+ *
+ * 使用 [OnlineAudio.urlForDownload] 获取文件下载链接.
+ *
+ * ## [Audio] 与 [Voice] 的转换
+ *
+ * 原 [Voice] 已弃用故不推荐进行兼容转换. [Audio] 将有稳定性保证, 请尽量使用新的 [Audio].
+ *
+ * 将 [Audio] 转为 [Voice]: [Voice.fromAudio]
+ * 将 [Voice] 转为 [Audio]: [Voice.toAudio]
+ *
+ * @since 2.7
+ */
+public sealed interface Audio : MessageContent {
+    public companion object Key :
+        AbstractPolymorphicMessageKey<MessageContent, Audio>(MessageContent, { it.safeCast() })
+
+    /**
+     * 文件名称. 通常为 `XXX.amr`. 服务器要求文件名后缀必须为 ".amr", 但其[编码方式][codec]也有可能是非 [AudioCodec.AMR].
+     */
+    public val filename: String
+
+    /**
+     * 文件 MD5. 16 bytes.
+     */
+    public val fileMd5: ByteArray
+
+    /**
+     * 文件大小 bytes. 官方客户端支持最大文件大小约为 1MB, 过大的文件**可能**可以正常上传, 但在官方客户端无法收听 (显示文件损坏).
+     */
+    public val fileSize: Long
+
+    /**
+     * 编码方式.
+     *
+     * - 若语音文件真实编码方式为 [AudioCodec.SILK], 而该属性为 [AudioCodec.AMR], 语音文件将会被服务器压缩为低音质 [AudioCodec.AMR] 格式.
+     * - 若语音文件真实编码方式为 [AudioCodec.AMR], 而该属性为 [AudioCodec.SILK], 语音也可以正常发送并在客户端收听, 音质随文件真实格式而决定.
+     *
+     * 因此在发送时 [codec] 通常可以总是使用 [AudioCodec.SILK] (这也是 [AudioSupported.uploadAudio] 的默认行为).
+     */
+    public val codec: AudioCodec
+
+    /**
+     * 文件的额外数据. 该数据由服务器提供, 可能会影响语音音质等属性.
+     * [extraData] 为 `null` 时也可以发送语音, 但不确定发送和客户端收听是否会正常.
+     *
+     * [extraData] 可能随服务器更新而更新, 因此请不要尝试解析该数据.
+     *
+     * [extraData] 向下兼容, 即旧版本的 [extraData] 可以在新版本构造 [OfflineAudio] ([OfflineAudio.Factory.create]).
+     */
+    public val extraData: ByteArray?
+
+    /**
+     * @return `"[mirai:audio:${filename}]"`
+     */
+    public override fun toString(): String
+    public override fun contentToString(): String = "[语音消息]"
+}
+
+
+/**
+ * 在线语音消息, 即从消息事件中接收到的语音消息.
+ *
+ * [OnlineAudio] 可以获取[语音长度][length]以及[下载链接][urlForDownload].
+ *
+ * [OnlineAudio] 仅可以从事件中的[消息链][MessageChain]接收, 不可手动构造. 若需要手动构造, 请使用 [OfflineAudio.Factory.create] 构造 [离线语音][OfflineAudio].
+ *
+ * ### 序列化支持
+ *
+ * [OnlineAudio] 支持序列化. 可使用 [MessageChain.serializeToJsonString] 以及 [MessageChain.deserializeFromJsonString].
+ * 也可以在 [MessageSerializers.serializersModule] 获取到 [OnlineAudio] 的 [KSerializer].
+ *
+ * 要获取更多有关序列化的信息, 参阅 [MessageSerializers].
+ *
+ * ### 不建议自行实现该接口
+ *
+ * [OnlineAudio] 不稳定, 将来可能会增加新的抽象属性或方法而导致不兼容. 仅可以使用该接口而不能继承或实现它.
+ *
+ * @since 2.7
+ * @see OfflineAudio
+ */
+@NotStableForInheritance
+public interface OnlineAudio : Audio { // 协议实现
+    /**
+     * 下载链接 HTTP URL.
+     * @return `"http://xxx"`
+     */
+    public val urlForDownload: String
+
+    /**
+     * 语音长度秒数
+     */
+    public val length: Long
+
+    public companion object Key :
+        AbstractPolymorphicMessageKey<Audio, OnlineAudio>(Audio, { it.safeCast() }) {
+
+        public const val SERIAL_NAME: String = "OnlineAudio"
+    }
+}
+
+/**
+ * 离线语音消息.
+ *
+ * [OfflineAudio] 仅拥有协议上必要的五个属性:
+ * - 文件名 [filename]
+ * - 文件 MD5 [fileMd5]
+ * - 文件大小 [fileSize]
+ * - 编码方式 [codec]
+ * - 额外数据 [extraData]
+ *
+ * [OfflineAudio] 可由本地 [ExternalResource] 经过 [AudioSupported.uploadAudio] 上传到服务器得到, 故无[下载链接][OnlineAudio.urlForDownload].
+ *
+ * [OfflineAudio] 同时还可以用做自定义构造 [Audio] 实例, 使用 [OfflineAudio.Factory.create] 可通过上述五个必要参数获得 [OfflineAudio] 实例.
+ *
+ * ### 序列化支持
+ *
+ * [OfflineAudio] 支持序列化. 可使用 [MessageChain.serializeToJsonString] 以及 [MessageChain.deserializeFromJsonString].
+ * 也可以在 [MessageSerializers.serializersModule] 获取到 [OfflineAudio] 的 [KSerializer].
+ *
+ * 要获取更多有关序列化的信息, 参阅 [MessageSerializers].
+ *
+ * ### 不建议自行实现该接口
+ *
+ * [OfflineAudio] 不稳定, 将来可能会增加新的抽象属性或方法而导致不兼容. 仅可以使用该接口而不能继承或实现它.
+ *
+ * @since 2.7
+ */
+@NotStableForInheritance
+public interface OfflineAudio : Audio {
+
+    public companion object Key :
+        AbstractPolymorphicMessageKey<Audio, OfflineAudio>(Audio, { it.safeCast() }) {
+        public const val SERIAL_NAME: String = "OfflineAudio"
+    }
+
+    public interface Factory {
+        /**
+         * 构造 [OfflineAudio]. 有关参数的含义, 参考 [Audio].
+         *
+         * 在 Kotlin 可以使用类构造器的函数 [OfflineAudio]: `OfflineAudio(...)`
+         */
+        public fun create(
+            filename: String,
+            fileMd5: ByteArray,
+            fileSize: Long,
+            codec: AudioCodec,
+            extraData: ByteArray?,
+        ): OfflineAudio
+
+        /**
+         * 使用 [OnlineAudio] 的信息构造 [OfflineAudio].
+         *
+         * 在 Kotlin 可以使用类构造器的函数 [OfflineAudio]: `OfflineAudio(...)`
+         */
+        public fun from(onlineAudio: OnlineAudio): OfflineAudio = onlineAudio.run {
+            create(filename, fileMd5, fileSize, codec, extraData)
+        }
+
+        public companion object INSTANCE :
+            Factory by loadService("net.mamoe.mirai.internal.message.OfflineAudioFactoryImpl")
+    }
+}
+
+/**
+ * 构造 [OfflineAudio]. 有关参数的含义, 参考 [Audio].
+ * @since 2.7
+ */
+@JvmSynthetic
+public inline fun OfflineAudio(
+    filename: String,
+    fileMd5: ByteArray,
+    fileSize: Long,
+    codec: AudioCodec,
+    extraData: ByteArray?,
+): OfflineAudio = OfflineAudio.Factory.create(filename, fileMd5, fileSize, codec, extraData)
+
+/**
+ * 使用 [OnlineAudio] 的信息构造 [OfflineAudio].
+ * @since 2.7
+ */
+@JvmSynthetic
+public inline fun OfflineAudio(
+    onlineAudio: OnlineAudio
+): OfflineAudio = OfflineAudio.Factory.from(onlineAudio)
+
+
+/**
+ * 语音编码方式.
+ *
+ * @since 2.7
+ */
+@Serializable(AudioCodec.AsIntSerializer::class)
+public enum class AudioCodec(
+    public val id: Int,
+    public val formatName: String,
+) {
+    /**
+     * 低音质编码格式
+     */
+    AMR(0, "amr"),
+
+    /**
+     * 高音质编码格式
+     */
+    SILK(1, "silk");
+
+    public companion object {
+        private val VALUES = values()
+
+        @JvmStatic
+        public fun fromId(id: Int): AudioCodec = VALUES.first { it.id == id }
+
+        @JvmStatic
+        public fun fromFormatName(formatName: String): AudioCodec = VALUES.first { it.formatName == formatName }
+
+        @JvmStatic
+        public fun fromIdOrNull(id: Int): AudioCodec? = VALUES.find { it.id == id }
+
+        @JvmStatic
+        public fun fromFormatNameOrNull(formatName: String): AudioCodec? =
+            VALUES.find { it.formatName == formatName }
+    }
+
+    internal object AsIntSerializer : KSerializer<AudioCodec> by Int.serializer().map(
+        Int.serializer().descriptor,
+        deserialize = { fromId(it) },
+        serialize = { id }
+    )
+}
\ No newline at end of file
diff --git a/mirai-core-api/src/commonMain/kotlin/message/data/Voice.kt b/mirai-core-api/src/commonMain/kotlin/message/data/Voice.kt
index 063fdc561..34e26784b 100644
--- a/mirai-core-api/src/commonMain/kotlin/message/data/Voice.kt
+++ b/mirai-core-api/src/commonMain/kotlin/message/data/Voice.kt
@@ -7,16 +7,20 @@
  * https://github.com/mamoe/mirai/blob/dev/LICENSE
  */
 
+@file:JvmMultifileClass
+@file:JvmName("MessageUtils")
+@file:Suppress("NOTHING_TO_INLINE")
+
 package net.mamoe.mirai.message.data
 
-import kotlinx.serialization.KSerializer
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 import kotlinx.serialization.Transient
-import net.mamoe.mirai.Mirai
 import net.mamoe.mirai.contact.Group
-import net.mamoe.mirai.utils.*
-import net.mamoe.mirai.utils.ExternalResource.Companion.uploadAsVoice
+import net.mamoe.mirai.utils.MiraiExperimentalApi
+import net.mamoe.mirai.utils.MiraiInternalApi
+import net.mamoe.mirai.utils.NotStableForInheritance
+import net.mamoe.mirai.utils.safeCast
 
 
 /**
@@ -49,56 +53,68 @@ public abstract class PttMessage : MessageContent {
     @MiraiInternalApi
     @Transient
     public var pttInternalInstance: Any? = null
-        set(value) {
-            field = value
-            _pttInternalInstanceSerializeCache = null
-        }
-
-    @MiraiInternalApi
-    protected val pttInternalInstanceSerializeCache: String
-        get() {
-            _pttInternalInstanceSerializeCache?.let { return it }
-            return Mirai.serializePttElem(pttInternalInstance).also {
-                _pttInternalInstanceSerializeCache = it
-            }
-        }
-
-    @Transient
-    private var _pttInternalInstanceSerializeCache: String? = null
 }
 
 /**
- * 语音消息, 目前只支持接收和转发
+ * 已弃用的旧版本语音消息.
  *
- * 目前, 使用 [Voice] 类型是稳定的, 但调用 [Voice] 中的属性 [fileName], [md5], [fileSize] 是不稳定的. 语音的序列化也可能会在未来有变动.
+ * [Voice] 由于有设计缺陷已弃用且可能会在将来版本删除, 请使用 [Audio].
  *
- * ## 使用语音
+ * ## 迁移指南
  *
- * 可以通过 [ExternalResource.uploadAsVoice] 或者 [Group.uploadVoice] 上传语音文件到服务器, 得到 [Voice] 实例. 但这不会发送给目标群.
- * 上传后需要通过 [Group.sendMessage] 发送 [Voice] 实例.
- *
- * [Voice] 实例可以通过序列化方式保存. 下次可以用它发送因而不需要上传. 但可能由于未来服务器更新, 这项功能就不稳定. 因此建议总是上传音频文件而不要保存 [Voice].
+ * - 将使用的 [Voice] 类型替换为 [Audio] 类型
+ * - 将 [Group.uploadVoice] 替换为 [Group.uploadAudio]
+ * - 如果有必须使用旧 [Voice] 类型的情况, 请使用 [Audio.toVoice]
  */
-@Suppress("DuplicatedCode")
-@Serializable(Voice.Serializer::class) // experimental
+@Suppress("DuplicatedCode", "DEPRECATION")
+@Serializable
 @SerialName(Voice.SERIAL_NAME)
-public class Voice @MiraiInternalApi constructor(
+@Deprecated(
+    "Please use Audio instead.",
+    replaceWith = ReplaceWith("Audio", "net.mamoe.mirai.message.data.Audio"),
+    level = DeprecationLevel.WARNING
+)
+public open class Voice @MiraiInternalApi constructor(
     @MiraiExperimentalApi public override val fileName: String,
     @MiraiExperimentalApi public override val md5: ByteArray,
     @MiraiExperimentalApi public override val fileSize: Long,
 
-    @MiraiInternalApi public val codec: Int = 0,
+    @SerialName("codec") @MiraiInternalApi public val _codec: Int = 0,
     private val _url: String
 ) : PttMessage() {
 
     public companion object Key : AbstractPolymorphicMessageKey<PttMessage, Voice>(PttMessage, { it.safeCast() }) {
         public const val SERIAL_NAME: String = "Voice"
+
+        /**
+         * 将 2.7 新增的 [Audio] 转为旧版本的 [Voice], 以兼容某些情况.
+         *
+         * @see Audio.toVoice
+         * @since 2.7
+         */
+        @Suppress("DeprecatedCallableAddReplaceWith")
+        @Deprecated(
+            "Please consider migrating to Audio",
+            level = DeprecationLevel.WARNING
+        )
+        @JvmStatic
+        public fun fromAudio(audio: Audio): Voice {
+            audio.run {
+                return Voice(
+                    filename,
+                    fileMd5,
+                    fileSize,
+                    codec.id,
+                    if (this is OnlineAudio) kotlin.runCatching { urlForDownload }.getOrElse { "" } else ""
+                )
+            }
+        }
     }
 
     /**
      * 下载链接 HTTP URL.
      */
-    public val url: String?
+    public open val url: String?
         get() = when {
             _url.isBlank() -> null
             _url.startsWith("http") -> _url
@@ -115,69 +131,54 @@ public class Voice @MiraiInternalApi constructor(
 
     public override fun contentToString(): String = "[语音消息]"
 
+    /**
+     * 转换为 2.7 新增的 [Audio], 以兼容某些无法迁移的情况.
+     *
+     * @since 2.7
+     */
+    public fun toAudio(): Audio {
+        val voice = this
+        return OfflineAudio(
+            voice.fileName,
+            voice.md5,
+            voice.fileSize,
+            AudioCodec.fromIdOrNull(voice._codec) ?: AudioCodec.SILK,
+            byteArrayOf()
+        )
+    }
+
     override fun equals(other: Any?): Boolean {
         if (this === other) return true
         if (other !is Voice) return false
 
-        if (this.pttInternalInstance != null && other.pttInternalInstance != null) {
-            if (this.pttInternalInstance == other.pttInternalInstance)
-                return true
-            // strict
-            return this.pttInternalInstanceSerializeCache == other.pttInternalInstanceSerializeCache
-        }
-
         if (fileName != other.fileName) return false
         if (!md5.contentEquals(other.md5)) return false
         if (fileSize != other.fileSize) return false
-        if (codec != other.codec) return false
+        if (_codec != other._codec) return false
         if (_url != other._url) return false
 
         return true
     }
 
     override fun hashCode(): Int {
-        if (pttInternalInstance != null)
-            return pttInternalInstanceSerializeCache.hashCode()
-
         var result = fileName.hashCode()
         result = 12 * result + md5.contentHashCode()
         result = 54 * result + fileSize.hashCode()
-        result = 33 * result + codec
+        result = 33 * result + _codec
         result = 15 * result + _url.hashCode()
         return result
     }
+}
 
-    public object Serializer : KSerializer<Voice> by VoiceS.serializer().map(
-        resultantDescriptor = VoiceS.serializer().descriptor.copy(SERIAL_NAME),
-        deserialize = {
-            Voice(
-                fileName = it.fileName,
-                md5 = it.md5,
-                fileSize = it.fileSize,
-                codec = it.codec,
-                _url = it._url,
-            ).also { v -> v.pttInternalInstance = Mirai.deserializePttElem(it.ptt) }
-        },
-        serialize = {
-            VoiceS(
-                fileName = it.fileName,
-                md5 = it.md5,
-                fileSize = it.fileSize,
-                _url = it._url,
-                codec = it.codec,
-                ptt = Mirai.serializePttElem(it.pttInternalInstance)
-            )
-        }
-    ) {
-        @Serializable
-        @SerialName(SERIAL_NAME)
-        private class VoiceS(
-            val fileName: String,
-            val md5: ByteArray,
-            val fileSize: Long,
-            val codec: Int,
-            val _url: String,
-            val ptt: String = "",
-        )
-    }
-}
\ No newline at end of file
+/**
+ * 将 2.7 新增的 [Audio] 转为旧版本的 [Voice], 以兼容某些情况.
+ *
+ * @since 2.7
+ */
+@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
+@Deprecated(
+    "Please migrate to Audio",
+    level = DeprecationLevel.WARNING
+)
+@JvmSynthetic
+public inline fun Audio.toVoice(): Voice = Voice.fromAudio(this)
\ No newline at end of file
diff --git a/mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt b/mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt
index 3f3636bea..56f8ac3cd 100644
--- a/mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt
+++ b/mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt
@@ -19,13 +19,12 @@ import net.mamoe.mirai.contact.Contact
 import net.mamoe.mirai.contact.Contact.Companion.sendImage
 import net.mamoe.mirai.contact.Contact.Companion.uploadImage
 import net.mamoe.mirai.contact.FileSupported
-import net.mamoe.mirai.contact.VoiceSupported
+import net.mamoe.mirai.contact.Group
 import net.mamoe.mirai.internal.utils.ExternalResourceImplByByteArray
 import net.mamoe.mirai.internal.utils.ExternalResourceImplByFile
 import net.mamoe.mirai.message.MessageReceipt
 import net.mamoe.mirai.message.data.FileMessage
 import net.mamoe.mirai.message.data.Image
-import net.mamoe.mirai.message.data.Voice
 import net.mamoe.mirai.message.data.sendTo
 import net.mamoe.mirai.utils.ExternalResource.Companion.sendAsImageTo
 import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
@@ -416,58 +415,15 @@ public interface ExternalResource : Closeable {
         // region uploadAsVoice
         ///////////////////////////////////////////////////////////////////////////
 
-        /**
-         * 将文件作为语音上传后构造 [Voice]. 上传后只会得到 [Voice] 实例, 而不会将语音发送到目标群或好友.
-         *
-         * **服务器仅支持音频格式 `silk` 或 `amr`**. 需要调用方手动[关闭资源][ExternalResource.close].
-         *
-         * @throws OverFileSizeMaxException
-         * @since 2.7
-         * @see VoiceSupported.uploadVoice
-         */
+        @Suppress("DEPRECATION")
         @JvmBlockingBridge
         @JvmStatic
-        public suspend fun ExternalResource.uploadAsVoice(contact: VoiceSupported): Voice {
-            return contact.uploadVoice(this)
-        }
-
-        @JvmBlockingBridge
-        @JvmStatic
-        @Deprecated("For binary compatibility", level = DeprecationLevel.WARNING)
-        @JvmName("uploadAsVoice")
-        public suspend fun ExternalResource.uploadAsVoice(contact: Contact): Voice {
-            if (contact is VoiceSupported) return contact.uploadVoice(this)
+        @Deprecated("Use `contact.uploadAudio(resource)` instead", level = DeprecationLevel.WARNING)
+        public suspend fun ExternalResource.uploadAsVoice(contact: Contact): net.mamoe.mirai.message.data.Voice {
+            @Suppress("DEPRECATION")
+            if (contact is Group) return contact.uploadVoice(this)
             else throw UnsupportedOperationException("Contact `$contact` is not supported uploading voice")
         }
-
-        /**
-         * 读取 [InputStream] 到临时文件并将其作为语音上传至 [contact] 后构造 [Voice],
-         * 上传后只会得到 [Voice] 实例, 而不会将语音发送到目标群或好友
-         *
-         * 注意:本函数不会关闭流.
-         *
-         * @since 2.7
-         * @throws OverFileSizeMaxException
-         * @see VoiceSupported.uploadVoice
-         */
-        @JvmStatic
-        @JvmBlockingBridge
-        public suspend fun InputStream.uploadAsVoice(contact: VoiceSupported): Voice =
-            runBIO { toExternalResource() }.withUse { uploadAsVoice(contact) }
-
-        /**
-         * 将文件作为语音上传后构造 [Voice].
-         * 上传后只会得到 [Voice] 实例, 而不会将语音发送到目标群或好友
-         *
-         * @since 2.7
-         * @throws OverFileSizeMaxException
-         * @see VoiceSupported.uploadVoice
-         */
-        @JvmStatic
-        @JvmBlockingBridge
-        public suspend fun File.uploadAsVoice(contact: VoiceSupported): Voice =
-            toExternalResource().withUse { uploadAsVoice(contact) }
-
         // endregion
     }
 }
diff --git a/mirai-core-utils/src/commonMain/kotlin/Bytes.kt b/mirai-core-utils/src/commonMain/kotlin/Bytes.kt
index 5f4da0bf2..733beb89f 100644
--- a/mirai-core-utils/src/commonMain/kotlin/Bytes.kt
+++ b/mirai-core-utils/src/commonMain/kotlin/Bytes.kt
@@ -149,7 +149,7 @@ public fun UByteArray.toUHexString(separator: String = " ", offset: Int = 0, len
 }
 
 public inline fun ByteArray.encodeToString(offset: Int = 0, charset: Charset = Charsets.UTF_8): String =
-    kotlinx.io.core.String(this, charset = charset, offset = offset, length = this.size - offset)
+    String(this, charset = charset, offset = offset, length = this.size - offset)
 
 public expect fun ByteArray.encodeBase64(): String
 public expect fun String.decodeBase64(): ByteArray
diff --git a/mirai-core/src/commonMain/kotlin/MiraiImpl.kt b/mirai-core/src/commonMain/kotlin/MiraiImpl.kt
index 63e7cd27f..472201b3c 100644
--- a/mirai-core/src/commonMain/kotlin/MiraiImpl.kt
+++ b/mirai-core/src/commonMain/kotlin/MiraiImpl.kt
@@ -124,6 +124,14 @@ internal open class MiraiImpl : IMirai, LowLevelApiAccessor {
                 UnsupportedMessageImpl::class,
                 UnsupportedMessageImpl.serializer()
             )
+            MessageSerializers.registerSerializer(
+                OnlineAudioImpl::class,
+                OnlineAudioImpl.serializer()
+            )
+            MessageSerializers.registerSerializer(
+                OfflineAudioImpl::class,
+                OfflineAudioImpl.serializer()
+            )
         }
     }
 
@@ -965,14 +973,4 @@ internal open class MiraiImpl : IMirai, LowLevelApiAccessor {
             }
         }
     }
-
-    override fun serializePttElem(ptt: Any?): String {
-        if (ptt !is ImMsgBody.Ptt) return ""
-        return ptt.toByteArray(ImMsgBody.Ptt.serializer()).toUHexString()
-    }
-
-    override fun deserializePttElem(ptt: String): Any? {
-        if (ptt.isBlank()) return null
-        return ptt.hexToBytes().loadAs(ImMsgBody.Ptt.serializer())
-    }
 }
diff --git a/mirai-core/src/commonMain/kotlin/contact/FriendImpl.kt b/mirai-core/src/commonMain/kotlin/contact/FriendImpl.kt
index a33fc9294..d0d80a0ba 100644
--- a/mirai-core/src/commonMain/kotlin/contact/FriendImpl.kt
+++ b/mirai-core/src/commonMain/kotlin/contact/FriendImpl.kt
@@ -9,11 +9,7 @@
 
 @file:OptIn(LowLevelApi::class)
 @file:Suppress(
-    "EXPERIMENTAL_API_USAGE",
-    "DEPRECATION_ERROR",
     "NOTHING_TO_INLINE",
-    "INVISIBLE_MEMBER",
-    "INVISIBLE_REFERENCE"
 )
 
 package net.mamoe.mirai.internal.contact
@@ -28,18 +24,19 @@ import net.mamoe.mirai.event.events.FriendMessagePostSendEvent
 import net.mamoe.mirai.event.events.FriendMessagePreSendEvent
 import net.mamoe.mirai.internal.QQAndroidBot
 import net.mamoe.mirai.internal.contact.info.FriendInfoImpl
+import net.mamoe.mirai.internal.message.OfflineAudioImpl
 import net.mamoe.mirai.internal.network.highway.*
 import net.mamoe.mirai.internal.network.protocol.data.proto.Cmd0x346
 import net.mamoe.mirai.internal.network.protocol.data.proto.ImMsgBody
 import net.mamoe.mirai.internal.network.protocol.packet.chat.voice.PttStore
-import net.mamoe.mirai.internal.network.protocol.packet.chat.voice.voiceCodec
+import net.mamoe.mirai.internal.network.protocol.packet.chat.voice.audioCodec
 import net.mamoe.mirai.internal.network.protocol.packet.list.FriendList
 import net.mamoe.mirai.internal.utils.C2CPkgMsgParsingCache
 import net.mamoe.mirai.internal.utils.io.serialization.loadAs
 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.Voice
+import net.mamoe.mirai.message.data.OfflineAudio
 import net.mamoe.mirai.utils.ExternalResource
 import net.mamoe.mirai.utils.recoverCatchingSuppressed
 import net.mamoe.mirai.utils.toByteArray
@@ -78,10 +75,9 @@ internal class FriendImpl(
             "Friend ${this.id} had already been deleted"
         }
         bot.network.run {
-            FriendList.DelFriend.invoke(bot.client, this@FriendImpl)
-                .sendAndExpect<FriendList.DelFriend.Response>().also {
-                    check(it.isSuccess) { "delete friend failed: ${it.resultCode}" }
-                }
+            FriendList.DelFriend.invoke(bot.client, this@FriendImpl).sendAndExpect().also {
+                check(it.isSuccess) { "delete friend failed: ${it.resultCode}" }
+            }
         }
     }
 
@@ -95,19 +91,13 @@ internal class FriendImpl(
 
     override fun toString(): String = "Friend($id)"
 
-    override suspend fun uploadVoice(resource: ExternalResource): Voice = bot.network.run {
-        val voice = Voice(
-            "${resource.md5.toUHexString("")}.amr",
-            resource.md5,
-            resource.size,
-            resource.voiceCodec,
-            ""
-        )
+    override suspend fun uploadAudio(resource: ExternalResource): OfflineAudio = bot.network.run {
+        var audio: OfflineAudioImpl? = null
         kotlin.runCatching {
             val resp = Highway.uploadResourceBdh(
                 bot = bot,
                 resource = resource,
-                kind = ResourceKind.PRIVATE_VOICE,
+                kind = ResourceKind.PRIVATE_AUDIO,
                 commandId = 26,
                 extendInfo = PttStore.C2C.createC2CPttStoreBDHExt(bot, this@FriendImpl.uin, resource)
                     .toByteArray(Cmd0x346.ReqBody.serializer())
@@ -117,14 +107,20 @@ internal class FriendImpl(
             if (c346resp.msgApplyUploadRsp == null) {
                 error("Upload failed")
             }
-            voice.pttInternalInstance = ImMsgBody.Ptt(
-                fileType = 4,
-                srcUin = bot.uin,
-                fileUuid = c346resp.msgApplyUploadRsp.uuid,
+            audio = OfflineAudioImpl(
+                filename = "${resource.md5.toUHexString("")}.amr",
                 fileMd5 = resource.md5,
-                fileName = resource.md5 + ".amr".toByteArray(),
-                fileSize = resource.size.toInt(),
-                boolValid = true,
+                fileSize = resource.size,
+                codec = resource.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(),
+                    boolValid = true,
+                )
             )
         }.recoverCatchingSuppressed {
             when (val resp = PttStore.GroupPttUp(bot.client, bot.id, id, resource).sendAndExpect<Any>()) {
@@ -133,24 +129,30 @@ internal class FriendImpl(
                         bot,
                         resp.uploadIpList.zip(resp.uploadPortList),
                         resource.size,
-                        ResourceKind.GROUP_VOICE,
+                        ResourceKind.GROUP_AUDIO,
                         ChannelKind.HTTP
                     ) { ip, port ->
                         Mirai.Http.postPtt(ip, port, resource, resp.uKey, resp.fileKey)
                     }
-                    voice.pttInternalInstance = ImMsgBody.Ptt(
-                        fileType = 4,
-                        srcUin = bot.uin,
-                        fileUuid = resp.fileId.toByteArray(),
+                    audio = OfflineAudioImpl(
+                        filename = "${resource.md5.toUHexString("")}.amr",
                         fileMd5 = resource.md5,
-                        fileName = resource.md5 + ".amr".toByteArray(),
-                        fileSize = resource.size.toInt(),
-                        boolValid = true,
+                        fileSize = resource.size,
+                        codec = resource.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(),
+                            boolValid = true,
+                        )
                     )
                 }
             }
         }.getOrThrow()
 
-        return voice
+        return audio!!
     }
 }
diff --git a/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt b/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt
index 929bf63f5..63f773f9c 100644
--- a/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt
+++ b/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt
@@ -23,22 +23,25 @@ import net.mamoe.mirai.event.events.*
 import net.mamoe.mirai.internal.QQAndroidBot
 import net.mamoe.mirai.internal.contact.announcement.AnnouncementsImpl
 import net.mamoe.mirai.internal.contact.info.MemberInfoImpl
+import net.mamoe.mirai.internal.message.OfflineAudioImpl
 import net.mamoe.mirai.internal.message.OfflineGroupImage
 import net.mamoe.mirai.internal.network.components.BdhSession
 import net.mamoe.mirai.internal.network.handler.NetworkHandler
 import net.mamoe.mirai.internal.network.handler.logger
 import net.mamoe.mirai.internal.network.highway.ChannelKind
 import net.mamoe.mirai.internal.network.highway.Highway
+import net.mamoe.mirai.internal.network.highway.ResourceKind.GROUP_AUDIO
 import net.mamoe.mirai.internal.network.highway.ResourceKind.GROUP_IMAGE
-import net.mamoe.mirai.internal.network.highway.ResourceKind.GROUP_VOICE
 import net.mamoe.mirai.internal.network.highway.postPtt
 import net.mamoe.mirai.internal.network.highway.tryServersUpload
 import net.mamoe.mirai.internal.network.protocol.data.proto.Cmd0x388
 import net.mamoe.mirai.internal.network.protocol.packet.chat.TroopEssenceMsgManager
 import net.mamoe.mirai.internal.network.protocol.packet.chat.image.ImgStore
 import net.mamoe.mirai.internal.network.protocol.packet.chat.voice.PttStore
+import net.mamoe.mirai.internal.network.protocol.packet.chat.voice.audioCodec
 import net.mamoe.mirai.internal.network.protocol.packet.chat.voice.voiceCodec
 import net.mamoe.mirai.internal.network.protocol.packet.list.ProfileService
+import net.mamoe.mirai.internal.network.protocol.packet.sendAndExpect
 import net.mamoe.mirai.internal.utils.GroupPkgMsgParsingCache
 import net.mamoe.mirai.internal.utils.RemoteFileImpl
 import net.mamoe.mirai.internal.utils.io.serialization.toByteArray
@@ -204,32 +207,10 @@ internal class GroupImpl(
         }
     }
 
+    @Suppress("OverridingDeprecatedMember", "DEPRECATION")
     override suspend fun uploadVoice(resource: ExternalResource): Voice {
         return bot.network.run {
-            kotlin.runCatching {
-                val (_) = Highway.uploadResourceBdh(
-                    bot = bot,
-                    resource = resource,
-                    kind = GROUP_VOICE,
-                    commandId = 29,
-                    extendInfo = PttStore.GroupPttUp.createTryUpPttPack(bot.id, id, resource)
-                        .toByteArray(Cmd0x388.ReqBody.serializer()),
-                )
-            }.recoverCatchingSuppressed {
-                when (val resp = PttStore.GroupPttUp(bot.client, bot.id, id, resource).sendAndExpect<Any>()) {
-                    is PttStore.GroupPttUp.Response.RequireUpload -> {
-                        tryServersUpload(
-                            bot,
-                            resp.uploadIpList.zip(resp.uploadPortList),
-                            resource.size,
-                            GROUP_VOICE,
-                            ChannelKind.HTTP
-                        ) { ip, port ->
-                            Mirai.Http.postPtt(ip, port, resource, resp.uKey, resp.fileKey)
-                        }
-                    }
-                }
-            }.getOrThrow()
+            uploadAudioResource(resource)
 
             // val body = resp?.loadAs(Cmd0x388.RspBody.serializer())
             //     ?.msgTryupPttRsp
@@ -243,6 +224,51 @@ internal class GroupImpl(
                 ""
             )
         }
+    }
+
+    private suspend fun uploadAudioResource(resource: ExternalResource) {
+        kotlin.runCatching {
+            val (_) = Highway.uploadResourceBdh(
+                bot = bot,
+                resource = resource,
+                kind = GROUP_AUDIO,
+                commandId = 29,
+                extendInfo = PttStore.GroupPttUp.createTryUpPttPack(bot.id, id, resource)
+                    .toByteArray(Cmd0x388.ReqBody.serializer()),
+            )
+        }.recoverCatchingSuppressed {
+            when (val resp = PttStore.GroupPttUp(bot.client, bot.id, id, resource).sendAndExpect(bot)) {
+                is PttStore.GroupPttUp.Response.RequireUpload -> {
+                    tryServersUpload(
+                        bot,
+                        resp.uploadIpList.zip(resp.uploadPortList),
+                        resource.size,
+                        GROUP_AUDIO,
+                        ChannelKind.HTTP
+                    ) { ip, port ->
+                        Mirai.Http.postPtt(ip, port, resource, resp.uKey, resp.fileKey)
+                    }
+                }
+            }
+        }.getOrThrow()
+    }
+
+    override suspend fun uploadAudio(resource: ExternalResource): OfflineAudio {
+        return bot.network.run {
+            uploadAudioResource(resource)
+
+            // 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,
+                originalPtt = null,
+            )
+        }
 
     }
 
diff --git a/mirai-core/src/commonMain/kotlin/message/OnlineAudioImpl.kt b/mirai-core/src/commonMain/kotlin/message/OnlineAudioImpl.kt
new file mode 100644
index 000000000..9ad621c3f
--- /dev/null
+++ b/mirai-core/src/commonMain/kotlin/message/OnlineAudioImpl.kt
@@ -0,0 +1,302 @@
+/*
+ * 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.internal.message
+
+import kotlinx.io.core.toByteArray
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import net.mamoe.mirai.internal.network.protocol.data.proto.ImMsgBody
+import net.mamoe.mirai.internal.utils.io.ProtoBuf
+import net.mamoe.mirai.internal.utils.io.serialization.loadAs
+import net.mamoe.mirai.internal.utils.io.serialization.toByteArray
+import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.utils.copy
+import net.mamoe.mirai.utils.map
+
+
+/**
+ * ## Audio Implementation Overview
+ *
+ * ```
+ *                     (api)Audio
+ *                          |
+ *                    /------------------\
+ *         (api)OnlineAudio        (api)OfflineAudio
+ *              |                         |
+ *              |                         |
+ * (core)OnlineAudioImpl      (core)OfflineAudioImpl
+ * ```
+ *
+ * - [OnlineAudioImpl]: 实现从 [ImMsgBody.Ptt] 解析
+ * - [OfflineAudioImpl]: 支持用户手动构造
+ *
+ * ## Equality
+ *
+ * - [OnlineAudio] != [OfflineAudio]
+ *
+ * ## Converting [Audio] to [ImMsgBody.Ptt]
+ *
+ * Always call [Audio.toPtt]
+ */
+internal interface AudioPttSupport : MessageContent { // Audio is sealed in mirai-core-api
+    /**
+     * 原协议数据. 用于在接受到其他用户发送的语音时能按照原样发回.
+     */
+    val originalPtt: ImMsgBody.Ptt?
+}
+
+@Serializable
+internal class AudioExtraData(
+    @ProtoNumber(1) val ptt: ImMsgBody.Ptt?,
+) : ProtoBuf {
+    fun toByteArray(): ByteArray {
+        return Wrapper(CURRENT_VERSION, this).toByteArray(Wrapper.serializer())
+    }
+
+    companion object {
+        @Serializable
+        class Wrapper(
+            @ProtoNumber(1) val version: Int,
+            @ProtoNumber(2) val v1: AudioExtraData,
+        ) : ProtoBuf
+
+        private const val CURRENT_VERSION = 1
+
+
+        fun loadFrom(byteArray: ByteArray?): AudioExtraData? {
+            byteArray ?: return null
+            return kotlin.runCatching {
+                byteArray.loadAs(Wrapper.serializer()).v1
+            }.getOrNull()
+        }
+    }
+}
+
+internal fun Audio.toPtt(): ImMsgBody.Ptt {
+    if (this is AudioPttSupport) {
+        this.originalPtt?.let { return it }
+    }
+    return ImMsgBody.Ptt(
+        fileName = this.filename.toByteArray(),
+        fileMd5 = this.fileMd5,
+        boolValid = true,
+        fileSize = this.fileSize.toInt(),
+        fileType = 4,
+        pbReserve = byteArrayOf(0),
+        format = this.codec.id
+    )
+}
+
+@SerialName(OnlineAudio.SERIAL_NAME)
+@Serializable(OnlineAudioImpl.Serializer::class)
+internal class OnlineAudioImpl(
+    override val filename: String,
+    override val fileMd5: ByteArray,
+    override val fileSize: Long,
+    override val codec: AudioCodec,
+    url: String,
+    override val length: Long,
+    override val originalPtt: ImMsgBody.Ptt?,
+) : OnlineAudio, AudioPttSupport {
+    private val _url = refineUrl(url)
+
+    override val extraData: ByteArray? by lazy {
+        AudioExtraData(originalPtt).toByteArray()
+    }
+
+    override val urlForDownload: String
+        get() = _url.takeIf { it.isNotBlank() }
+            ?: throw UnsupportedOperationException("Could not fetch URL for audio $filename")
+
+    private val _stringValue: String by lazy { "[mirai:audio:${filename}]" }
+    override fun toString(): String = _stringValue
+
+    @Suppress("DuplicatedCode")
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as OnlineAudioImpl
+
+        if (filename != other.filename) return false
+        if (!fileMd5.contentEquals(other.fileMd5)) return false
+        if (fileSize != other.fileSize) return false
+        if (_url != other._url) return false
+        if (codec != other.codec) return false
+        if (length != other.length) return false
+        if (originalPtt != other.originalPtt) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = super.hashCode()
+        result = 31 * result + filename.hashCode()
+        result = 31 * result + fileMd5.contentHashCode()
+        result = 31 * result + fileSize.hashCode()
+        result = 31 * result + _url.hashCode()
+        result = 31 * result + codec.hashCode()
+        result = 31 * result + length.hashCode()
+        result = 31 * result + originalPtt.hashCode()
+        return result
+    }
+
+
+    companion object {
+        fun refineUrl(url: String) = when {
+            url.isBlank() -> ""
+            url.startsWith("http") -> url
+            url.startsWith("/") -> "$DOWNLOAD_URL$url"
+            else -> "$DOWNLOAD_URL/$url"
+        }
+
+        @Suppress("HttpUrlsUsage")
+        const val DOWNLOAD_URL = "http://grouptalk.c2c.qq.com"
+    }
+
+    object Serializer : KSerializer<OnlineAudioImpl> by Surrogate.serializer().map(
+        resultantDescriptor = Surrogate.serializer().descriptor.copy(OnlineAudio.SERIAL_NAME),
+        deserialize = {
+            OnlineAudioImpl(
+                filename = filename,
+                fileMd5 = fileMd5,
+                fileSize = fileSize,
+                url = urlForDownload,
+                codec = codec,
+                length = length,
+                originalPtt = AudioExtraData.loadFrom(extraData)?.ptt
+            )
+        },
+        serialize = {
+            Surrogate(
+                filename = filename,
+                fileMd5 = fileMd5,
+                fileSize = fileSize,
+                urlForDownload = urlForDownload,
+                codec = codec,
+                length = length,
+                extraData = extraData
+            )
+        }
+    ) {
+        @Serializable
+        @SerialName(OnlineAudio.SERIAL_NAME)
+        private class Surrogate(
+            override val filename: String,
+            override val fileMd5: ByteArray,
+            override val fileSize: Long,
+            override val codec: AudioCodec,
+            override val length: Long,
+            override val extraData: ByteArray?,
+            override val urlForDownload: String,
+        ) : OnlineAudio {
+            override fun toString(): String {
+                return "Surrogate(filename='$filename', fileMd5=${fileMd5.contentToString()}, fileSize=$fileSize, codec=$codec, length=$length, extraData=${extraData.contentToString()}, urlForDownload='$urlForDownload')"
+            }
+        }
+    }
+}
+
+@SerialName(OfflineAudio.SERIAL_NAME)
+@Serializable(OfflineAudioImpl.Serializer::class)
+internal class OfflineAudioImpl(
+    override val filename: String,
+    override val fileMd5: ByteArray,
+    override val fileSize: Long,
+    override val codec: AudioCodec,
+    override val originalPtt: ImMsgBody.Ptt?,
+) : OfflineAudio, AudioPttSupport {
+    constructor(
+        filename: String,
+        fileMd5: ByteArray,
+        fileSize: Long,
+        codec: AudioCodec,
+        extraData: ByteArray?,
+    ) : this(filename, fileMd5, fileSize, codec, AudioExtraData.loadFrom(extraData)?.ptt)
+
+    override val extraData: ByteArray? by lazy {
+        AudioExtraData(originalPtt).toByteArray()
+    }
+
+    private val _stringValue: String by lazy { "[mirai:audio:${filename}]" }
+    override fun toString(): String = _stringValue
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as OfflineAudioImpl
+
+        if (filename != other.filename) return false
+        if (!fileMd5.contentEquals(other.fileMd5)) return false
+        if (fileSize != other.fileSize) return false
+        if (codec != other.codec) return false
+        if (originalPtt != other.originalPtt) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = filename.hashCode()
+        result = 31 * result + fileMd5.contentHashCode()
+        result = 31 * result + fileSize.hashCode()
+        result = 31 * result + codec.hashCode()
+        result = 31 * result + originalPtt.hashCode()
+        return result
+    }
+
+    object Serializer : KSerializer<OfflineAudioImpl> by Surrogate.serializer().map(
+        resultantDescriptor = Surrogate.serializer().descriptor.copy(OfflineAudio.SERIAL_NAME),
+        deserialize = {
+            OfflineAudioImpl(
+                filename = filename,
+                fileMd5 = fileMd5,
+                fileSize = fileSize,
+                codec = codec,
+                extraData = extraData,
+            )
+        },
+        serialize = {
+            Surrogate(
+                filename = filename,
+                fileMd5 = fileMd5,
+                fileSize = fileSize,
+                codec = codec,
+                extraData = extraData,
+            )
+        }
+    ) {
+        @Serializable
+        @SerialName(OfflineAudio.SERIAL_NAME)
+        private class Surrogate(
+            override val filename: String,
+            override val fileMd5: ByteArray,
+            override val fileSize: Long,
+            override val codec: AudioCodec,
+            override val extraData: ByteArray?,
+        ) : OfflineAudio {
+            override fun toString(): String {
+                return "OfflineAudio(filename='$filename', fileMd5=${fileMd5.contentToString()}, fileSize=$fileSize, codec=$codec, extraData=${extraData.contentToString()})"
+            }
+        }
+    }
+}
+
+@PublishedApi
+internal class OfflineAudioFactoryImpl : OfflineAudio.Factory {
+    override fun create(
+        filename: String,
+        fileMd5: ByteArray,
+        fileSize: Long,
+        codec: AudioCodec,
+        extraData: ByteArray?
+    ): OfflineAudio = OfflineAudioImpl(filename, fileMd5, fileSize, codec, extraData)
+}
\ No newline at end of file
diff --git a/mirai-core/src/commonMain/kotlin/message/ReceiveMessageHandler.kt b/mirai-core/src/commonMain/kotlin/message/ReceiveMessageHandler.kt
index 088f485a9..a7f1c473f 100644
--- a/mirai-core/src/commonMain/kotlin/message/ReceiveMessageHandler.kt
+++ b/mirai-core/src/commonMain/kotlin/message/ReceiveMessageHandler.kt
@@ -18,15 +18,12 @@ import net.mamoe.mirai.internal.message.DeepMessageRefiner.refineDeep
 import net.mamoe.mirai.internal.message.LightMessageRefiner.refineLight
 import net.mamoe.mirai.internal.message.ReceiveMessageTransformer.cleanupRubbishMessageElements
 import net.mamoe.mirai.internal.message.ReceiveMessageTransformer.joinToMessageChain
-import net.mamoe.mirai.internal.message.ReceiveMessageTransformer.toVoice
+import net.mamoe.mirai.internal.message.ReceiveMessageTransformer.toAudio
 import net.mamoe.mirai.internal.network.protocol.data.proto.*
 import net.mamoe.mirai.internal.utils.io.serialization.loadAs
 import net.mamoe.mirai.internal.utils.io.serialization.readProtoBuf
 import net.mamoe.mirai.message.data.*
-import net.mamoe.mirai.utils.encodeToString
-import net.mamoe.mirai.utils.read
-import net.mamoe.mirai.utils.toUHexString
-import net.mamoe.mirai.utils.unzip
+import net.mamoe.mirai.utils.*
 
 /**
  * 只在手动构造 [OfflineMessageSource] 时调用
@@ -91,7 +88,7 @@ private fun List<MsgComm.Msg>.toMessageChain(
     joinToMessageChain(elements, groupIdOrZero, messageSourceKind, bot, builder)
 
     for (msg in messageList) {
-        msg.msgBody.richText.ptt?.toVoice()?.let { builder.add(it) }
+        msg.msgBody.richText.ptt?.toAudio()?.let { builder.add(it) }
     }
 
     return builder.build().cleanupRubbishMessageElements()
@@ -516,11 +513,13 @@ internal object ReceiveMessageTransformer {
         }
     }
 
-    fun ImMsgBody.Ptt.toVoice() = Voice(
-        kotlinx.io.core.String(fileName),
-        fileMd5,
-        fileSize.toLong(),
-        format,
-        kotlinx.io.core.String(downPara)
-    ).also { it.pttInternalInstance = this }
+    fun ImMsgBody.Ptt.toAudio() = OnlineAudioImpl(
+        filename = fileName.encodeToString(),
+        fileMd5 = fileMd5,
+        fileSize = fileSize.toLongUnsigned(),
+        codec = AudioCodec.fromId(format),
+        url = downPara.encodeToString(),
+        length = time.toLongUnsigned(),
+        originalPtt = this,
+    )
 }
\ No newline at end of file
diff --git a/mirai-core/src/commonMain/kotlin/network/highway/Highway.kt b/mirai-core/src/commonMain/kotlin/network/highway/Highway.kt
index aa9014a8b..4b0f83567 100644
--- a/mirai-core/src/commonMain/kotlin/network/highway/Highway.kt
+++ b/mirai-core/src/commonMain/kotlin/network/highway/Highway.kt
@@ -122,8 +122,8 @@ internal enum class ResourceKind(
 ) {
     PRIVATE_IMAGE("private image"),
     GROUP_IMAGE("group image"),
-    PRIVATE_VOICE("private voice"),
-    GROUP_VOICE("group voice"),
+    PRIVATE_AUDIO("private audio"),
+    GROUP_AUDIO("group audio"),
 
     GROUP_FILE("group file"),
 
diff --git a/mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Msg.kt b/mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Msg.kt
index 87f8c9034..3cbd88616 100644
--- a/mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Msg.kt
+++ b/mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Msg.kt
@@ -765,7 +765,69 @@ internal class ImMsgBody : ProtoBuf {
         @ProtoNumber(30) @JvmField val pbReserve: ByteArray = EMPTY_BYTE_ARRAY,
         @ProtoNumber(31) @JvmField val bytesPttUrls: List<ByteArray> = emptyList(),
         @ProtoNumber(32) @JvmField val downloadFlag: Int = 0,
-    ) : ProtoBuf
+    ) : ProtoBuf {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (javaClass != other?.javaClass) return false
+
+            other as Ptt
+
+            if (fileType != other.fileType) return false
+            if (srcUin != other.srcUin) return false
+            if (!fileUuid.contentEquals(other.fileUuid)) return false
+            if (!fileMd5.contentEquals(other.fileMd5)) return false
+            if (!fileName.contentEquals(other.fileName)) return false
+            if (fileSize != other.fileSize) return false
+            if (!reserve.contentEquals(other.reserve)) return false
+            if (fileId != other.fileId) return false
+            if (serverIp != other.serverIp) return false
+            if (serverPort != other.serverPort) return false
+            if (boolValid != other.boolValid) return false
+            if (!signature.contentEquals(other.signature)) return false
+            if (!shortcut.contentEquals(other.shortcut)) return false
+            if (!fileKey.contentEquals(other.fileKey)) return false
+            if (magicPttIndex != other.magicPttIndex) return false
+            if (voiceSwitch != other.voiceSwitch) return false
+            if (!pttUrl.contentEquals(other.pttUrl)) return false
+            if (!groupFileKey.contentEquals(other.groupFileKey)) return false
+            if (time != other.time) return false
+            if (!downPara.contentEquals(other.downPara)) return false
+            if (format != other.format) return false
+            if (!pbReserve.contentEquals(other.pbReserve)) return false
+            if (bytesPttUrls != other.bytesPttUrls) return false
+            if (downloadFlag != other.downloadFlag) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            var result = fileType
+            result = 31 * result + srcUin.hashCode()
+            result = 31 * result + fileUuid.contentHashCode()
+            result = 31 * result + fileMd5.contentHashCode()
+            result = 31 * result + fileName.contentHashCode()
+            result = 31 * result + fileSize
+            result = 31 * result + reserve.contentHashCode()
+            result = 31 * result + fileId
+            result = 31 * result + serverIp
+            result = 31 * result + serverPort
+            result = 31 * result + boolValid.hashCode()
+            result = 31 * result + signature.contentHashCode()
+            result = 31 * result + shortcut.contentHashCode()
+            result = 31 * result + fileKey.contentHashCode()
+            result = 31 * result + magicPttIndex
+            result = 31 * result + voiceSwitch
+            result = 31 * result + pttUrl.contentHashCode()
+            result = 31 * result + groupFileKey.contentHashCode()
+            result = 31 * result + time
+            result = 31 * result + downPara.contentHashCode()
+            result = 31 * result + format
+            result = 31 * result + pbReserve.contentHashCode()
+            result = 31 * result + bytesPttUrls.hashCode()
+            result = 31 * result + downloadFlag
+            return result
+        }
+    }
 
     @Serializable
     internal class PubAccInfo(
diff --git a/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/receive/MessageSvc.PbSendMsg.kt b/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/receive/MessageSvc.PbSendMsg.kt
index 0f80ec9b4..fc5f801a8 100644
--- a/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/receive/MessageSvc.PbSendMsg.kt
+++ b/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/receive/MessageSvc.PbSendMsg.kt
@@ -145,7 +145,8 @@ internal object MessageSvcPbSendMsg : OutgoingPacketFactory<MessageSvcPbSendMsg.
         return response
     }
 
-    internal fun PttMessage.toPtt() = run {
+    // old Voice
+    private fun PttMessage.toPtt() = run {
         (this.pttInternalInstance as? ImMsgBody.Ptt)?.let { return it }
         ImMsgBody.Ptt(
             fileName = fileName.toByteArray(),
@@ -155,8 +156,9 @@ internal object MessageSvcPbSendMsg : OutgoingPacketFactory<MessageSvcPbSendMsg.
             fileType = 4,
             pbReserve = byteArrayOf(0),
             format = let {
+                @Suppress("DEPRECATION")
                 if (it is Voice) {
-                    it.codec
+                    it._codec
                 } else {
                     0
                 }
@@ -244,7 +246,7 @@ internal object MessageSvcPbSendMsg : OutgoingPacketFactory<MessageSvcPbSendMsg.
                 ImMsgBody.MsgBody(
                     richText = ImMsgBody.RichText(
                         elems = subChain.toRichTextElems(messageTarget = targetFriend, withGeneralFlags = true),
-                        ptt = subChain[PttMessage]?.toPtt(),
+                        ptt = subChain.findPtt(),
                     )
                 )
             },
@@ -361,7 +363,7 @@ internal object MessageSvcPbSendMsg : OutgoingPacketFactory<MessageSvcPbSendMsg.
                 ImMsgBody.MsgBody(
                     richText = ImMsgBody.RichText(
                         elems = subChain.toRichTextElems(messageTarget = targetGroup, withGeneralFlags = true),
-                        ptt = subChain[PttMessage]?.toPtt()
+                        ptt = subChain.findPtt()
 
                     )
                 )
@@ -409,6 +411,9 @@ internal object MessageSvcPbSendMsg : OutgoingPacketFactory<MessageSvcPbSendMsg.
             doFragmented = fragmented
         )
     }
+
+    private fun MessageChain.findPtt() =
+        findIsInstance<Audio>()?.toPtt() ?: this[PttMessage]?.toPtt()
     /*
     = buildOutgoingUniPacket(client) {
         ///writeFully("0A 08 0A 06 08 89 FC A6 8C 0B 12 06 08 01 10 00 18 00 1A 1F 0A 1D 12 08 0A 06 0A 04 F0 9F 92 A9 12 11 AA 02 0E 88 01 00 9A 01 08 78 00 F8 01 00 C8 02 00 20 9B 7A 28 F4 CA 9B B8 03 32 34 08 92 C2 C4 F1 05 10 92 C2 C4 F1 05 18 E6 ED B9 C3 02 20 89 FE BE A4 06 28 89 84 F9 A2 06 48 DE 8C EA E5 0E 58 D9 BD BB A0 09 60 1D 68 92 C2 C4 F1 05 70 00 40 01".hexToBytes())
diff --git a/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/voice/PttStore.kt b/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/voice/PttStore.kt
index 0c36fd20e..1b7a4c61f 100644
--- a/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/voice/PttStore.kt
+++ b/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/voice/PttStore.kt
@@ -22,18 +22,21 @@ import net.mamoe.mirai.internal.network.protocol.packet.buildOutgoingUniPacket
 import net.mamoe.mirai.internal.utils.io.serialization.readProtoBuf
 import net.mamoe.mirai.internal.utils.io.serialization.writeProtoBuf
 import net.mamoe.mirai.internal.utils.toIpV4AddressString
+import net.mamoe.mirai.message.data.AudioCodec
 import net.mamoe.mirai.utils.EMPTY_BYTE_ARRAY
 import net.mamoe.mirai.utils.ExternalResource
 import net.mamoe.mirai.utils.encodeToString
 import net.mamoe.mirai.utils.toUHexString
 
-internal val ExternalResource.voiceCodec: Int
+internal inline val ExternalResource.voiceCodec: Int get() = audioCodec.id
+
+internal val ExternalResource.audioCodec: AudioCodec
     get() {
         return when (formatName) {
             // 实际上 amr 是 0, 但用 1 也可以发. 为了避免 silk 错被以 amr 发送导致降音质就都用 1
-            "amr" -> 1  // amr
-            "silk" -> 1  // silk V3
-            else -> 1     // use amr by default
+            "amr" -> AudioCodec.SILK
+            "silk" -> AudioCodec.SILK
+            else -> AudioCodec.AMR     // use amr by default
         }
     }
 
diff --git a/mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.message.data.OfflineAudio.Factory b/mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.message.data.OfflineAudio.Factory
new file mode 100644
index 000000000..d995a6641
--- /dev/null
+++ b/mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.message.data.OfflineAudio.Factory
@@ -0,0 +1,10 @@
+#
+# 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
+#
+
+net.mamoe.mirai.internal.message.OfflineAudioFactoryImpl
\ No newline at end of file
diff --git a/mirai-core/src/commonTest/kotlin/message/AudioTest.kt b/mirai-core/src/commonTest/kotlin/message/AudioTest.kt
new file mode 100644
index 000000000..f02720f3d
--- /dev/null
+++ b/mirai-core/src/commonTest/kotlin/message/AudioTest.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.internal.message
+
+import net.mamoe.mirai.internal.message.OnlineAudioImpl.Companion.DOWNLOAD_URL
+import net.mamoe.mirai.internal.message.OnlineAudioImpl.Companion.refineUrl
+import net.mamoe.mirai.internal.network.protocol.data.proto.ImMsgBody
+import net.mamoe.mirai.internal.test.AbstractTest
+import net.mamoe.mirai.message.data.AudioCodec
+import net.mamoe.mirai.message.data.OfflineAudio
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+
+internal class AudioTest : AbstractTest() {
+
+    @Test
+    fun `test factory`() {
+        assertEquals(
+            OfflineAudio("name", byteArrayOf(), 1, AudioCodec.SILK, byteArrayOf()),
+            OfflineAudio("name", byteArrayOf(), 1, AudioCodec.SILK, byteArrayOf())
+        )
+    }
+
+    @Test
+    fun `invalid extraData is refreshed`() {
+        assertContentEquals(
+            OfflineAudio("test", byteArrayOf(), 1, AudioCodec.SILK, null).extraData,
+            OfflineAudio("test", byteArrayOf(), 1, AudioCodec.SILK, byteArrayOf(1, 2, 3)).extraData,
+        )
+    }
+
+    @Test
+    fun `test equality`() {
+        assertEquals(
+            OnlineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, "url", 2, null),
+            OnlineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, "url", 2, null)
+        )
+        assertEquals(
+            OnlineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, "url", 2, null),
+            OnlineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, "url", 2, null)
+        )
+        assertEquals(
+            OfflineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, originalPtt = null),
+            OfflineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, originalPtt = null)
+        )
+        assertEquals(
+            OfflineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, byteArrayOf()),
+            OfflineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, byteArrayOf())
+        )
+        assertEquals(
+            OfflineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, ImMsgBody.Ptt(srcUin = 2)),
+            OfflineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, ImMsgBody.Ptt(srcUin = 2))
+        )
+    }
+
+    @Test
+    fun `test refineUrl`() {
+        assertFalse { DOWNLOAD_URL.endsWith("/") }
+
+        assertEquals("", refineUrl(""))
+        assertEquals("$DOWNLOAD_URL/test", refineUrl("/test"))
+        assertEquals("$DOWNLOAD_URL/test", refineUrl("test"))
+        assertEquals("https://custom.com", refineUrl("https://custom.com"))
+        assertEquals("http://localhost", refineUrl("http://localhost"))
+    }
+}
\ No newline at end of file
diff --git a/mirai-core/src/jvmTest/kotlin/message/data/MessageSerializationTest.kt b/mirai-core/src/jvmTest/kotlin/message/data/MessageSerializationTest.kt
index 10dbc2aa7..abcf30fe6 100644
--- a/mirai-core/src/jvmTest/kotlin/message/data/MessageSerializationTest.kt
+++ b/mirai-core/src/jvmTest/kotlin/message/data/MessageSerializationTest.kt
@@ -12,13 +12,12 @@ package net.mamoe.mirai.internal.message.data
 import kotlinx.serialization.KSerializer
 import kotlinx.serialization.Polymorphic
 import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.*
 import kotlinx.serialization.serializer
 import net.mamoe.mirai.Mirai
 import net.mamoe.mirai.internal.message.FileMessageImpl
 import net.mamoe.mirai.internal.message.MarketFaceImpl
+import net.mamoe.mirai.internal.message.OnlineAudioImpl
 import net.mamoe.mirai.internal.message.UnsupportedMessageImpl
 import net.mamoe.mirai.internal.network.protocol.data.proto.ImMsgBody
 import net.mamoe.mirai.internal.utils._miraiContentToString
@@ -220,39 +219,100 @@ internal class MessageSerializationTest {
 
     @Serializable
     data class V(
-        val msg: Voice
+        val msg: Audio
+    )
+
+    @Serializable
+    data class AudioTestStandard(
+        val online: OnlineAudio,
+        val offline: OfflineAudio,
+        val onlineAsRec: Audio,
+        val offlineAsRec: Audio,
     )
 
     @Test
-    fun `test Voice serialization`() {
-        val v = V(Voice("4517", byteArrayOf(14), 50, 3, "https://github.com"))
-        println(v.serialize(V.serializer()))
+    fun `test Audio standard`() {
+        val origin = AudioTestStandard(
+            OnlineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, "url", 2, null),
+            OfflineAudio("test", byteArrayOf(), 1, AudioCodec.SILK, null),
+
+            OnlineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, "url", 2, null),
+            OfflineAudio("test", byteArrayOf(), 1, AudioCodec.SILK, null),
+        )
+
         assertEquals(
-            v.serialize(V.serializer()),
-            v.serialize(V.serializer())
-                .deserialize(V.serializer())
-                .serialize(V.serializer())
+            AudioCodec.SILK.id,
+            format.encodeToJsonElement(origin).jsonObject["offline"]!!.jsonObject["codec"]!!.jsonPrimitive.content.toInt()
+        ) // use custom serializer
+
+        assertEquals(
+            AudioCodec.SILK.id,
+            format.encodeToJsonElement(origin).jsonObject["online"]!!.jsonObject["codec"]!!.jsonPrimitive.content.toInt()
+        ) // use custom serializer
+
+        assertEquals(
+            "OnlineAudio",
+            format.encodeToJsonElement(origin).jsonObject["online"]!!.jsonObject["type"]!!.jsonPrimitive.content
         )
         assertEquals(
-            v,
-            v.serialize(V.serializer()).deserialize(V.serializer())
+            "OfflineAudio",
+            format.encodeToJsonElement(origin).jsonObject["offline"]!!.jsonObject["type"]!!.jsonPrimitive.content
         )
-        v.msg.pttInternalInstance = ImMsgBody.Ptt(
-            srcUin = 1234567890,
-            fileMd5 = byteArrayOf(14, 81, 37, 14),
-            boolValid = true,
-            format = 90,
-        )
-        println(v.serialize(V.serializer()))
+
         assertEquals(
-            v.serialize(V.serializer()),
-            v.serialize(V.serializer())
-                .deserialize(V.serializer())
-                .serialize(V.serializer())
+            "OnlineAudio",
+            format.encodeToJsonElement(origin).jsonObject["onlineAsRec"]!!.jsonObject["type"]!!.jsonPrimitive.content
         )
         assertEquals(
-            v,
-            v.serialize(V.serializer()).deserialize(V.serializer())
+            "OfflineAudio",
+            format.encodeToJsonElement(origin).jsonObject["offlineAsRec"]!!.jsonObject["type"]!!.jsonPrimitive.content
         )
+
+        val result = origin.serialize().deserialize<AudioTestStandard>()
+
+        assertEquals(origin.online::class, result.online::class)
+        assertEquals(origin.offline::class, result.offline::class)
+        assertEquals(origin.onlineAsRec::class, result.onlineAsRec::class)
+        assertEquals(origin.offlineAsRec::class, result.offlineAsRec::class)
+
+        assertEquals(origin.online, result.online)
+        assertEquals(origin.offline, result.offline)
+        assertEquals(origin.onlineAsRec, result.onlineAsRec)
+        assertEquals(origin.offlineAsRec, result.offlineAsRec)
+
+        assertEquals(origin, result)
+    }
+
+    @Serializable
+    data class AudioTestWithPtt(
+        val online: OnlineAudio,
+        val offline: OfflineAudio,
+        val onlineAsRec: Audio,
+        val offlineAsRec: Audio,
+    )
+
+    @Test
+    fun `test Audio with ptt`() {
+        val origin = AudioTestWithPtt(
+            OnlineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, "url", 2, ImMsgBody.Ptt(1)),
+            OfflineAudio("test", byteArrayOf(), 1, AudioCodec.SILK, byteArrayOf(1, 2)),
+
+            OnlineAudioImpl("name", byteArrayOf(), 1, AudioCodec.SILK, "url", 2, ImMsgBody.Ptt(1)),
+            OfflineAudio("test", byteArrayOf(), 1, AudioCodec.SILK, byteArrayOf(1, 2)),
+        )
+
+        val result = origin.serialize().deserialize<AudioTestWithPtt>()
+
+        assertEquals(origin.online::class, result.online::class)
+        assertEquals(origin.offline::class, result.offline::class)
+        assertEquals(origin.onlineAsRec::class, result.onlineAsRec::class)
+        assertEquals(origin.offlineAsRec::class, result.offlineAsRec::class)
+
+        assertEquals(origin.online, result.online)
+        assertEquals(origin.offline, result.offline)
+        assertEquals(origin.onlineAsRec, result.onlineAsRec)
+        assertEquals(origin.offlineAsRec, result.offlineAsRec)
+
+        assertEquals(origin, result)
     }
 }
\ No newline at end of file