1.基于阿里云Oss音视频异步处理拼接服务
官方文档: help.aliyun.com/zh/oss/user…
具体实现逻辑:
注意点:
1.需要先把待拼接的多个文件上传到oss云端,参数audioObjectKeys 指的是oss云端文件的位置,同样需要对每个文件路径进行base64编码,通过pre/sur指定文件的拼接的前后顺序
2.参数b 指的是 目标Bucket名称,名称需经过URL安全的Base64编码。如果不指定目标Bucket,则默认保存至原文件所在Bucket,其实就是对代码中targetObjectKey进行base64编码后指定的值
3.oss 服务需要提前开启imm服务,oss相关权限,消息样式等,以及mns 相关配置等,有点麻烦恶心
因为是异步拼接,需要引入阿里云的mns 消息队列服务,发送端只需要指定要发送的topic就可以,mns其他信息发送端不用关心,云端指定就可以
4.oss拼接服务和mns服务都需要额外收费,成本较高
5.需要启动个异步任务消费mns消息,通过task_id关联,下面是发送端拼接逻辑
func AsycOssAudioContact(ctx context.Context, audioObjectKeys []string, targetObjectKey string) (taskID string, err error) {
// 只有一段tts,不支持拼接
if len(audioObjectKeys) <= 1 {
return "", errors.New("AsycOssAudioContact audioObjectKeys must lt 1")
}
if targetObjectKey == "" {
return "", errors.New("AsycOssAudioContact targetObject is empty")
}
bucket, _, err := r.oss.GetOssBucketByName("my_tts_contact")
if err != nil {
r.log.WithContext(ctx).Errorf("AsycOssAudioContact err:%+v", err)
return "", err
}
bucketName := bucket.BucketName
// 将 objectKeys 进行 Base64 编码,并生成处理参数
var params []string
for k, objectKey := range audioObjectKeys {
if k == 0 {
continue // 这里是因为调用bucket.AsyncProcessObject()方法时第一个参数指定了第一段音频
}
encodedObjectKey := base64.URLEncoding.EncodeToString([]byte(objectKey))
params = append(params, fmt.Sprintf("/sur,o_%s", encodedObjectKey))
}
topic := conf.BaseConf.TtsContactMns.Topic
encodedTopic := base64.URLEncoding.EncodeToString([]byte(topic))
// 指定处理参数
// https://help.aliyun.com/zh/oss/user-guide/audio-stitching?spm=a2c4g.11186623.0.i2
style := fmt.Sprintf("audio/concat,f_mp3,ab_128000,align_0%s/notify,topic_%s", strings.Join(params, ""), encodedTopic)
process := fmt.Sprintf("%s|sys/saveas,b_%v,o_%v", style, strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(bucketName)), "="), strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(targetObjectKey)), "="))
// 调用 AsyncProcessObject 方法
rs, err := bucket.AsyncProcessObject(audioObjectKeys[0], process)
if err != nil {
r.log.WithContext(ctx).Errorf("AsyncProcessObject err:%+v,audioObjectKeys:%+v", err, audioObjectKeys)
return "", err
}
fmt.Printf("EventId:%s\n", rs.EventId)
fmt.Printf("RequestId:%s\n", rs.RequestId)
fmt.Printf("TaskId:%s\n", rs.TaskId)
return rs.TaskId, err
}
2.基于wav文件格式的音频进行内存拼接处理,参数为文件本身的base64编码
package tts_contact
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"time"
"github.com/go-audio/audio"
"github.com/go-audio/wav"
"github.com/orcaman/writerseeker"
)
func Base64ToReader(base64Str string) (*bytes.Reader, error) {
data, err := base64.StdEncoding.DecodeString(base64Str)
if err != nil {
return nil, err
}
reader := bytes.NewReader(data)
return reader, nil
}
func WavContact(audioBase64Str []string) (stream []byte, duration float64, err error) {
//定义一个包含所有WAV文件路径的数组
//audioBase64Str = []string{
//"/Users/peanut/Desktop/w1.wav",
//"/Users/peanut/Desktop/w2.wav",
//}
startTime := time.Now()
duration = 0.0
// 创建一个新的PCM缓冲区用于保存合并后的音频数据
outBuf := &audio.IntBuffer{
Data: []int{},
Format: &audio.Format{},
}
// // 创建一个新的WAV文件用于保存合并后的音频
// outFile, err := os.Create("output.wav")
// if err != nil {
// return nil, duration, fmt.Errorf("error creating output file:%+v", err.Error())
// }
// defer outFile.Close()
// 创建一个 WriterSeeker 实例
writerSeeker := &writerseeker.WriterSeeker{}
bitDepth := 16 // 假设位深度为16,可以根据实际情况调整
// 在音频处理中,"位深度"(bit depth)是一个重要的概念,它决定了音频的动态范围。位深度越高,
// 音频的动态范围越大,音质越好。常见的位深度有8位、16位、24位和32位。
// 这里假设位深度为16,这是因为16位是CD音质的标准,对于大多数应用来说已经足够好了。
// 但是,如果你需要处理高质量的音频,你可能需要使用更高的位深度,如24位或32位。
// 遍历每个文件
for _, audio := range audioBase64Str {
// todo 音频采样率问题,必须保持一致,这里假设采样率是一致的
reader, err := Base64ToReader(audio)
if err != nil {
return nil, duration, fmt.Errorf("Base64ToReader err:%+v", err.Error())
}
// 读取WAV文件的头部信息和音频数据
decoder := wav.NewDecoder(reader)
// 检查是否有错误或不支持的格式
if !decoder.IsValidFile() {
fmt.Println("Invalid WAV file:", audio)
return nil, duration, fmt.Errorf("invalid wav")
}
buf, err := decoder.FullPCMBuffer()
if err != nil {
return nil, duration, fmt.Errorf("error reading pcm data from file:%+v", err.Error())
}
// 简单地将多通道降为单通道(仅保留第一个通道)
if buf.Format.NumChannels > 1 {
buf.Data = downmixToMono(buf.Data, buf.Format.NumChannels)
buf.Format.NumChannels = 1
}
// 将音频数据添加到输出缓冲区
outBuf.Data = append(outBuf.Data, buf.Data...)
outBuf.Format = buf.Format
}
// 采样率为空
if outBuf.Format.SampleRate == 0 {
return nil, duration, fmt.Errorf("error reading pcm sampleRate zero")
}
// 创建一个新的WAV编码器并写入合并后的音频数据到输出文件中,writerSeeker参数是把文件输出到文件内存中处理了,如果想指定直接输出文件,可以把writerSeeker改为上面的outFile参数就可以
encoder := wav.NewEncoder(writerSeeker, outBuf.Format.SampleRate, bitDepth, outBuf.Format.NumChannels, 1)
err = encoder.Write(outBuf)
if err != nil {
return nil, duration, fmt.Errorf("error writing merged pcm data to output file:%+v", err.Error())
}
err = encoder.Close()
if err != nil {
return nil, duration, fmt.Errorf("error closing encoder:%+v", err.Error())
}
// 重置读取位置到数据的开始位置
_, err = writerSeeker.Seek(0, io.SeekStart)
if err != nil {
return nil, duration, fmt.Errorf("error resetting writerSeeker position:%+v", err.Error())
}
// 创建一个足够大的[]byte来存储reader的所有内容
// 获取 WriterSeeker 中的数据
streamData := make([]byte, writerSeeker.BytesReader().Len())
_, err = writerSeeker.BytesReader().Read(streamData)
if err != nil {
return nil, duration, fmt.Errorf("error writerSeeker BytesReader err:%+v", err.Error())
}
if len(streamData) == 0 {
return nil, duration, fmt.Errorf("stream is empty")
}
// 计算合并后的音频时长
duration = float64(len(outBuf.Data)) / float64(outBuf.Format.SampleRate)
diffTime := time.Since(startTime)
fmt.Printf("successfully merged The duration of the merged audio is %v,diffTime:%+v\n", duration, diffTime)
return streamData, duration, nil
}
// downmixToMono 将多通道音频数据降为单通道,仅保留第一个通道的数据。
func downmixToMono(data []int, numChannels int) []int {
monoData := make([]int, len(data)/numChannels)
for i := range monoData {
monoData[i] = data[i*numChannels]
}
return monoData
}
3.通过ffmpeg 拼接音频内容
去重音频静音部分
import librosa
import matplotlib.pyplot as plt
import sys
from pydub import AudioSegment
def cut_audio(audio_file, x1, x2):
try:
# print(audio_file)
# 读取音频文件
y, sr = librosa.load(audio_file)
durationAll = len(y) / sr
# print(len(y), sr, durationAll)
if len(y) == 0:
# 加载音频文件
audio = AudioSegment.from_file(audio_file)
# 计算音频文件的长度
durationAll = len(audio) / 1000.0
# 获取音频数据和采样率
y = audio.get_array_of_samples()
sr = audio.frame_rate
# print("---again----")
# print(durationAll)
# 找到第一个大于或等于0.01的样本
# i = 0
for i in range(len(y)):
if abs(y[i]) >= 0.001:
break
# 计算时长
durationFirst = i / sr
# print(durationFirst)
# 找到最后一个大于或等于0.01的样本
for i in range(len(y) - 1, -1, -1):
if abs(y[i]) >= 0.001:
break
# 计算时长
durationSecond = i / sr
# print(durationAll - durationSecond)
c1 = durationFirst * x1
c2 = (durationAll - durationSecond) * x2
d = durationAll - c1 - c2
# print(c1, c2, d)
# 剔除静音部分
print("ffmpeg -y -fflags +genpts -copyts -i " + audio_file + " -ss 00:00:0" + str(round(c1, 2)) + " -t 00:00:0" + str(
round(d, 2)) + " " + audio_file)
# 绘制波形图
# plt.figure(figsize=(14, 5))
# plt.plot(y)
# plt.title('Waveform of Audio')
# plt.show()
except Exception as e:
# print("")
sys.exit(2)
if __name__ == "__main__":
audio_file = sys.argv[1]
x1 = float(sys.argv[2])
x2 = float(sys.argv[3])
cut_audio(audio_file, x1, x2)
对音频进行拼接
public function miniMaxTtsForPd($text)
{
$length = mb_strlen($text, 'UTF-8');
$result = [];
$charType = 0; //1中文 2英文 3标点符号
$temp = "";
for ($i = 0; $i < $length; $i++) {
$char = mb_substr($text, $i, 1, 'UTF-8');
$theType = 3;
if (preg_match('/[\x{4e00}-\x{9fa5}]/u', $char)) {
$theType = 1;
} elseif (ctype_alpha($char)) {
$theType = 2;
}
if ($charType == 0) {
$charType = $theType;
$temp = $char;
} elseif ($theType == 1 && $charType == 1) {
$temp .= $char;
} elseif ($theType == 2 && $charType == 2) {
$temp .= $char;
} elseif ($theType == 3) {
$temp .= $char;
} else {
$result[] = $temp;
$temp = $char;
$charType = $theType;
}
}
if ($temp != "") {
$result[] = $temp;
}
$files = [];
$time = date('YmdHis');
// 对音频进行静音处理,然后路径写入到list.txt文件中
foreach ($audioUrls as $index => $url) {
$fileName = $time . '-' . ($index + 1) . ".mp3";
$filePath = $tempDir . "/" . $fileName;
file_put_contents($filePath, file_get_contents($url));
$output = [];
exec("/usr/local/bin/python3.11 ./audio_cut.py" . ' ' . $filePath . ' 1 1', $output);
if (count($output) == 0) {
return "";
}
echo $output[0] . '.mp3' . "\n";
exec($output[0] . '.mp3');
$files[] = $fileName . '.mp3';
}
// 构建FFmpeg命令行参数
$listFilePath = $tempDir . "/list.txt";
$listFileContent = implode("\n", array_map(function ($value) {
return "file $value";
}, array_values($files)));
file_put_contents($listFilePath, $listFileContent);
$outFile = "/Users/peanut/Downloads/audio_temp/" . md5($text) . ".mp3";
$ffmpegCommand = "ffmpeg -f concat -safe 0 -i " . $listFilePath . " -c copy " . $outFile;
<!-- ffmpeg:这是 FFmpeg 的命令行工具,用于处理多媒体数据,如音频、视频等。
-f concat:这是一个输入选项,指定使用 concat 过滤器,用于拼接多个输入文件。
-safe 0:这是 concat 过滤器的一个选项,设置为 0 可以接受不安全的文件路径。
-i:这是一个输入文件选项,后面跟的是输入文件的路径。在这里,输入文件是一个文本文件,其中列出了所有要拼接的音频文件的路径。
-c copy:这是一个编码选项,copy 表示复制输入流,不进行任何编码操作,这样可以保持原始音频的质量。
$outFile:这是输出文件的路径,拼接后的音频文件将保存在这个路径 -->
// 执行FFmpeg命令行
exec($ffmpegCommand, $output, $returnCode);
if ($returnCode !== 0) {
echo "拼接音频文件失败\n";
}
echo "音频拼接文件"+$outFile;
return "";
}