三种语音tts拼接服务实现

141 阅读6分钟

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 "";
    }