Laf 实现语音文本相互转换,直接让 GPT 开口说话

laf目标

本文将用 Laf 实现以下功能:

  • 短文本转音频
  • 长文本转音频
  • 音频转文本

laf准备工作

注册百度智能云

进入百度智能云官网进行注册,地址:https://cloud.baidu.com/

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第1张图片

登陆进去之后找到右上角的产品服务-->语音技术。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第2张图片

首先领取一下免费的资源。


点进去会提示需要认证,直接个人认证一下就可以了。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第3张图片

把这里的语音识别语音合成都领取了。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第4张图片

领取完之后我们到应用列表中来,创建一个应用。


这里只需要输入应用名称和应用面描述即可,其他的选项不用动。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第5张图片

创建完成后我们得到了一个 API Key 和 Secret Key ,我们待会要用到。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第6张图片

编写 Laf 云函数

我们需要在 Laf 中创建三个云函数,分别写入以下代码。

函数一 baidu-api


这里修改第四行和第五行代码,改成你刚刚创建的应用的 API Key 和 Secret Key。
import cloud from '@lafjs/cloud'

// 配置百度应用API Key和Secret Key
const apiKey = 'your api key'
const secretKey = 'your secret key'

export default async function (ctx: FunctionContext) {
  const _body = ctx.body;
  const _query = ctx.query;
  const _type = _body.type ? _body.type : _query.type;
  //参数校验
  if (!_type) {
    return resultData(-1, '参数type不能为空!');
  }

  switch (_type) {
    case 'shortTextToVoice':
      //短文本转语音
      return await shortTextToVoice(_body.param);
    case 'longTextToVoice':
      //长文本转语音-创建任务
      return await longTextToVoice(_body.param);
    case 'searchTextToVoice':
      //长文本转语音-查询任务结果
      return await searchTextToVoice(_body.param);
    case 'createVoiceToText':
      //音频转写-创建任务      
      return await createVoiceToText(_body.param);
    case 'searchVoiceToText':
      //音频转写-查询任务结果
      return await searchVoiceToText(_body.param);
    default:
      return resultData(-1, '请检查参数type是否有误!');
  }
}

//短文本转语音
async function shortTextToVoice(param) {
  console.log('shortTextToVoice', param);
  const _param = param;
  //参数校验
  if (!_param.text) {
    return resultData(-1, '参数text不能为空!');
  }
  if (_param.text.length > 60) {
    return resultData(-1, '不能超过60个汉字或者字母数字!');
  }

  const access_token = await getAccessToken();
  if (!access_token) {
    return resultData(-1, 'AccessToken获取失败!');
  }

  try {
    let _text = _param.text;
    console.log('shortTextToVoice-->text编码后:', _text);
    let _cuid = Math.ceil(Math.random() * 1000000000000);

    let obj = null;
    await cloud.fetch({
      url: 'https://tsn.baidu.com/text2audio',
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Accept': '*/*'
      },
      data: {
        'tex': _text, //合成的文本,使用UTF-8编码。不超过60个汉字或者字母数字。
        'tok': access_token, //开放平台获取到的开发者access_token
        'cuid': _cuid, //用户唯一标识,用来计算UV值。建议填写能区分用户的机器 MAC 地址或 IMEI 码,长度为60字符以内
        'ctp': '1', //客户端类型选择,web端填写固定值1
        'lan': 'zh', //固定值zh。语言选择,目前只有中英文混合模式,填写固定值zh
        'spd': _param.spd ? _param.spd : '5', //语速,取值0-15,默认为5中语速
        'pit': _param.pit ? _param.pit : '5', //音调,取值0-15,默认为5中语调
        'vol': _param.vol ? _param.vol : '5', //音量,取值0-15,默认为5中音量(取值为0时为音量最小值,并非为无声)
        'per': _param.per ? _param.per : '1', //基础音库:度小宇=1,度小美=0,度逍遥(基础)=3,度丫丫=4;精品音库:度逍遥(精品)=5003,度小鹿=5118,度博文=106,度小童=110,度小萌=111,度米朵=103,度小娇=5
        'aue': _param.aue ? _param.aue : '3' //3为mp3格式(默认);4为pcm-16k;5为pcm-8k;6为wav(内容同pcm-16k); 注意aue=4或者6是语音识别要求的格式,但是音频内容不是语音识别要求的自然人发音,所以识别效果会受影响。
      },
      responseType: 'stream',
    }).then(function (res) {
      let content_type = res.headers['content-type'];
      if (res.status == 200 && content_type && content_type.toLowerCase().indexOf('audio') > -1) {
        obj = resultData(0, '成功!', res.data);
      }
      else {
        obj = resultData(-1, '语音合成失败,err_detail:' + res.data.err_detail);
      }
    }).catch(function (err) {
      console.log('短文本转语音异常!', err.message);
      obj = resultData(-1, '语音合成异常:' + err.message);
    });

    //语音合成成功,存储文件
    if (obj.code == 0) {
      let fileName = 'TextToVoice/' + _cuid;
      let _aue = _param.aue ? _param.aue : '3';
      switch (_aue) {
        case '4':
        case '5':
          fileName = fileName + '.pcm';
          break;
        case '6':
          fileName = fileName + '.wav';
          break;
        default:
          fileName = fileName + '.mp3';
          break;
      }
      //调用云函数存储文件
      const ret = await cloud.invoke('store-file', {
        body: {
          type: 'storeFile',
          param: {
            fileName: fileName,
            fileBody: obj.data,
            contentType: 'application/octet-stream'
          }
        }
      });
      if (ret.code == 0) {
        obj = resultData(0, '语音合成成功!', ret.data);
      }
      else {
        obj = resultData(-1, ret.msg);
      }
    }
    return obj;
  }
  catch (e) {
    return resultData(-1, '异常错误:' + e.message);
  }
}

//长文本转语音-创建任务
async function longTextToVoice(param) {
  console.log('longTextToVoice', param);
  const _param = param;
  //参数校验
  if (!_param.text || _param.text.length == 0) {
    return resultData(-1, '参数text不能为空!');
  }

  const access_token = await getAccessToken();
  if (!access_token) {
    return resultData(-1, 'AccessToken获取失败!');
  }

  try {
    let obj = null;
    await cloud.fetch({
      url: 'https://aip.baidubce.com/rpc/2.0/tts/v1/create?access_token=' + access_token,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      data: {
        'text': _param.text, //待合成的文本,需要为UTF-8编码;输入多段文本时,文本间会插入1s长度的空白间隔。总字数不超过10万个字符,1个中文字、英文字母、数字或符号均算作1个字符
        'format': _param.format ? _param.format : 'mp3-16k', //音频格式:'mp3-16k','mp3-48k','wav','pcm-8k','pcm-16k',默认为mp3-16k
        'voice': _param.voice ? _param.voice : 0, //基础音库:度小宇=1,度小美=0,度逍遥(基础)=3,度丫丫=4;精品音库:度逍遥(精品)=5003,度小鹿=5118,度博文=106,度小童=110,度小萌=111,度米朵=103,度小娇=5。默认为度小美
        'lang': 'zh', //固定值zh。语言选择,目前只有中英文混合模式,填写固定值zh
        'speed': _param.speed ? _param.speed : 5, //语速,取值0-15,默认为5中语速
        'pitch': _param.pitch ? _param.pitch : 5, //音调,取值0-15,默认为5中语调
        'volume': _param.volume ? _param.volume : 5, //音量,取值0-15,默认为5中音量(取值为0时为音量最小值,并非为无声)
        'enable_subtitle': _param.enable_subtitle ? _param.enable_subtitle : '0', //是否开启字幕:取值范围0, 1, 2,默认为0。0表示不开启字幕,1表示开启句级别字幕,2表示开启词级别字幕
      }
    }).then(function (res) {
      let d = res.data;
      if (res.status == 200 && d.task_id) {
        obj = resultData(0, '长文本转语音成功!', d);
      }
      else {
        obj = resultData(-1, '长文本转语音失败!' + d.error_msg);
      }
    }).catch(function (err) {
      console.log('长文本转语音异常!', err.message);
      obj = resultData(-1, '长文本转语音异常:' + err.message);
    });

    return obj;
  }
  catch (e) {
    return resultData(-1, '异常错误:' + e.message);
  }
}

//长文本转语音-查询任务结果
async function searchTextToVoice(param) {
  console.log('searchTextToVoice', param);
  const _param = param;
  //参数校验
  if (!_param.taskIds || _param.taskIds == 0) {
    return resultData(-1, '参数taskIds不能为空!');
  }

  const access_token = await getAccessToken();
  if (!access_token) {
    return resultData(-1, 'AccessToken获取失败!');
  }

  try {
    let obj = null;
    //长文本转语音-查询任务
    await cloud.fetch({
      url: 'https://aip.baidubce.com/rpc/2.0/tts/v1/query?access_token=' + access_token,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      data: {
        'task_ids': _param.taskIds, //任务id,推荐一次查询多个任务id,单次最多可查询200个
      }
    }).then(function (res) {
      let d = res.data;
      if (res.status == 200 && d.tasks_info) {
        obj = resultData(0, '长文本转语音-查询成功!', d.tasks_info);
      }
      else {
        obj = resultData(-1, '长文本转语音-查询失败!' + d.error_msg);
      }
    }).catch(function (err) {
      console.log('长文本转语音-查询异常!', err.message);
      obj = resultData(-1, '长文本转语音-查询异常:' + err.message);
    });

    return obj;
  }
  catch (e) {
    return resultData(-1, '异常错误:' + e.message);
  }
}

//音频转写-创建任务
async function createVoiceToText(param) {
  console.log('voiceToText->param', param);
  const _param = param;
  if (!_param.fileUrl || !_param.fileType || _param.fileType.toLowerCase().indexOf('audio') < 0) {
    return resultData(-1, '请上传音频文件!');
  }
  if (!_param.fileName) {
    return resultData(-1, '文件名称不能为空');
  }
  const _format = ['mp3', 'wav', 'pcm', 'm4a', 'amr'];
  let _fileName = _param.fileName.toLowerCase().split('.');
  if (_format.indexOf(_fileName[1]) < 0) {
    return resultData(-1, '仅支持mp3、wav、pcm、m4a、amr格式的音频文件!');
  }
  const limitSize = 50 * 1024 * 1024;
  if (!_param.fileSize || _param.fileSize > limitSize) {
    return resultData(-1, '音频文件大小不能超过50M!');
  }
  const access_token = await getAccessToken();
  if (!access_token) {
    return resultData(-1, 'AccessToken获取失败!');
  }

  try {
    //文件格式
    let fileFormat = _param.fileName.split('.')[1];
    let obj = null;
    await cloud.fetch({
      url: 'https://aip.baidubce.com/rpc/2.0/aasr/v1/create?access_token=' + access_token,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      data: {
        'speech_url': _param.fileUrl, //音频url,云端可外网访问的url链接,音频大小不超过500MB
        'format': fileFormat, //音频格式,['mp3', 'wav', 'pcm','m4a','amr']单声道,编码 16bits 位深
        'pid': _param && _param.pid ? _param.pid : 80001, //语言类型,[80001(中文语音近场识别模型极速版), 80006(中文音视频字幕模型,1737(英文模型)]
        'rate': 16000 //采样率,[16000] 固定值
      }
    }).then(function (res) {
      let d = res.data;
      if (res.status == 200 && d.task_id) {
        obj = resultData(0, '音频转写成功!', d);
      }
      else {
        obj = resultData(-1, '音频转写失败!' + d.error_msg);
      }
    }).catch(function (err) {
      console.log('音频转写异常!', err.message);
      obj = resultData(-1, '音频转写异常:' + err.message);
    });

    return obj;
  }
  catch (e) {
    return resultData(-1, '异常错误:' + e.message);
  }
}

//音频转写-查询任务结果
async function searchVoiceToText(param) {
  console.log('searchVoiceToText', param);
  const _param = param;
  //参数校验
  if (!_param.taskIds || _param.taskIds.length == 0) {
    return resultData(-1, '参数taskIds不能为空!');
  }

  const access_token = await getAccessToken();
  if (!access_token) {
    return resultData(-1, 'AccessToken获取失败!');
  }

  try {
    let obj = null;
    //音频转写-查询任务
    await cloud.fetch({
      url: 'https://aip.baidubce.com/rpc/2.0/aasr/v1/query?access_token=' + access_token,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      data: {
        'task_ids': _param.taskIds, //任务id,推荐一次查询多个任务id,单次最多可查询200个
      }
    }).then(function (res) {
      let d = res.data;
      if (res.status == 200 && d.tasks_info) {
        obj = resultData(0, '音频转写-查询成功!', d.tasks_info);
      }
      else {
        obj = resultData(-1, '音频转写-查询失败!' + d.error_msg);
      }
    }).catch(function (err) {
      console.log('音频转写-查询异常!', err.message);
      obj = resultData(-1, '音频转写-查询异常:' + err.message);
    });

    return obj;
  }
  catch (e) {
    return resultData(-1, '异常错误:' + e.message);
  }
}

// 获取AccessToken
async function getAccessToken() {
  let access_token = '';
  try {
    await cloud.fetch({
      url: 'https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=' + apiKey + '&client_secret=' + secretKey,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      }
    }).then(function (res) {
      let d = res.data;
      if (res.status == 200 && d.access_token) {
        access_token = d.access_token;
      }
      else {
        console.log('获取AccessToken失败!' + d.error_msg);
      }
    }).catch(function (err) {
      console.log('获取AccessToken异常!', err.message);
    });
  }
  catch (e) {
    console.log('异常错误:' + e.message);
  }
  return access_token;
}

//返回结果数据
function resultData(code = -1, msg = '', data = null) {
  return { code, msg, data }
}

函数二 store-file

import cloud from '@lafjs/cloud'
import { GetObjectCommand, S3 } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

//初始化
const s3Client = new S3({
  endpoint: process.env.OSS_EXTERNAL_ENDPOINT,
  region: process.env.OSS_REGION,
  credentials: {
    accessKeyId: process.env.OSS_ACCESS_KEY,
    secretAccessKey: process.env.OSS_ACCESS_SECRET
  },
  forcePathStyle: true,
})
//存储空间名称,不带 Laf 应用 appid
const bucketName = 'store-file'

export default async function (ctx: FunctionContext) {
  const _body = ctx.body;
  //参数校验
  if (!_body.type) {
    return resultData(-1, '参数type不能为空!');
  }

  switch (_body.type) {
    case 'storeFile':
      //存储文件
      return await storeFile(_body.param);
    default:
      return resultData(-1, '请检查参数type是否有误!');
  }
}

//存储文件
async function storeFile(param) {
  console.log('storeFile', param);
  const _param = param;
  //参数校验
  if (!_param.fileName || !_param.fileBody || !_param.contentType) {
    return resultData(-1, '参数fileName、fileBody或contentType不能为空!');
  }

  try {
    //文件存储
    const res = await uploadAppFile(_param.fileName, _param.fileBody, _param.contentType);
    console.log('文件存储结果:', res)
    if (res && res.$metadata && res.$metadata.httpStatusCode == 200) {
      //获取文件存储的绝对路径
      // const fileUrl = await getAppFileUrl(_param.fileName);
      const bucket = getInternalBucketName();
      const fileUrl = 'https://' + bucket + '.oss.laf.dev/' + _param.fileName;
      return resultData(0, '文件存储成功!', {
        fileUrl: fileUrl,
        fileName: _param.fileName
      });
    }
    return resultData(-1, '文件存储失败!');
  }
  catch (e) {
    return resultData(-1, '出现异常!', e.message);
  }
}

//拼接文件桶名字
function getInternalBucketName() {
  const appid = process.env.APP_ID;
  return `${appid}-${bucketName}`;
}

//上传文件
async function uploadAppFile(key, body, contentType) {
  const bucket = getInternalBucketName();
  const res = await s3Client
    .putObject({
      Bucket: bucket,
      Key: key,
      ContentType: contentType,
      Body: body,
    })
  return res;
}

//获取文件 url
async function getAppFileUrl(key) {
  const bucket = getInternalBucketName();
  const res = await getSignedUrl(s3Client, new GetObjectCommand({
    Bucket: bucket,
    Key: key,
  }));
  return res;
}

//删除文件
async function delAppFileUrl(key) {
  const bucket = getInternalBucketName()
  const res = await s3Client.deleteObject({
    Bucket: bucket,
    Key: key
  });
  return res;
}

//返回结果数据
function resultData(code = -1, msg = '', data = null) {
  return { code, msg, data }
}
函数三 upload-file
import cloud from '@lafjs/cloud'

const fs = require("fs")

export default async function (ctx: FunctionContext) {
  const _body = ctx.body;
  const _query = ctx.query;
  const _type = _body.type ? _body.type : _query.type;
  //参数校验
  if (!_type) {
    return resultData(-1, '参数type不能为空!');
  }

  const _files = ctx.files;
  switch (_type) {
    case 'uploadFile':
      //上传文件   
      return await uploadFile(_files);
    default:
      return resultData(-1, '请检查参数type是否有误!');
  }
}

//上传文件
async function uploadFile(files) {
  console.log('uploadFile->files', files);

  const _files = files;
  //参数校验
  if (!_files || _files.length == 0) {
    return resultData(-1, '未上传文件!');
  }
  const fileInfo = _files[0];
  if (!fileInfo.filename) {
    return resultData(-1, '文件名称为空!');
  }
  if (!fileInfo.mimetype) {
    return resultData(-1, '文件类型为空!');
  }

  try {
    //获取上传文件的对象
    let fileData = await fs.readFileSync(fileInfo.path);
    let fileName = 'TempFiles/' + fileInfo.filename;
    //检测文件是否有后缀名,且后缀名和类型是否匹配
    let _mimetype = fileInfo.mimetype.split('/');
    if (fileInfo.filename.split('.').length < 2 && fileInfo.filename.indexOf(_mimetype[1]) < 0) {
      //如果上传的图片没有后缀名,则在后面追加类型
      if (_mimetype[0] == 'image') {
        fileName = fileName + '.' + _mimetype[1];
      }
      else {
        //如果图片没有后缀名,则统一以wav的形式存储
        fileInfo.mimetype = 'audio/wave';
        fileName = fileName + '.wav';
      }
    }

    //调用云函数存储文件
    const ret = await cloud.invoke('store-file', {
      body: {
        type: 'storeFile',
        param: {
          fileName: fileName,
          fileBody: fileData,
          contentType: fileInfo.mimetype
        }
      }
    });

    if (ret.code != 0) {
      return resultData(-1, ret.msg);
    }
    //文件类型
    ret.data.fileType = fileInfo.mimetype;
    //文件大小
    ret.data.fileSize = fileInfo.size;
    return ret;
  }
  catch (e) {
    return resultData(-1, '异常错误:' + e.message);
  }
}

//返回结果数据
function resultData(code = -1, msg = '', data = null) {
  return { code, msg, data }
}

创建一个 bucket

在 Laf 的存储中创建一个名为 store-file 的 bucket 权限给公共读写。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第7张图片

laf开始使用

短文本转音频


短文本要求文本长度小于60,大于60的用长文本的方法

短文本转音频比较简单,只需要调用 baidu-api 函数就可以获得音频的 URL(这里的音频文件是存储在刚创建的 bucket 中)。

调用示例

在云函数 baidu-api 右侧的调试面板选择 POST 请求方法,body 中传入以下参数后点击运行。

{
  "type": "shortTextToVoice",
  "param": {
    "text": "今天五月初五端午节,祝大家端午节安康!"
  }
}

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第8张图片

OK 这里我们就获得了短文本转音频后的音频文件的 URL。

长文本转音频

由于长文本和短文本不一样,当内容过多之后不能实时的返回音频文件,故需要先创建任务,然后再通过任务 ID 去查询生成的音频文件。

调用示例

在云函数 baidu-api 右侧的调试面板选择 POST 请求方法,body 中传入以下参数后点击运行。


这里 text 是一个数组,里面可以放一个很长的字符串,也可以放多个字符串,区别是多个字符串中间朗读会有停顿。字符总长度不能超过十万。
{
    "type": "longTextToVoice",
    "param": {
        "text": ["今天五月初五端午节","祝大家端午节安康!"]
    }
    
}

这样我们就创建了一个任务,并且得到了这个任务的 ID。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第9张图片

然后我们根据这个任务的 ID 来查询转换之后的音频文件,继续调用此函数传入以下参数。


这里的 taskIds 换成你刚刚得到的任务 ID
{
    "type": "searchTextToVoice",
    "param": {
        "taskIds": ["649804c2d9ab330001cf1ea6"]
    }
}

调用后我们会得到这个任务目前的状态和音频文件的 URL。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第10张图片

音频文件转文本

想要把音频文件转成文本,首先需要这个文件的 URL,你可以手动上传到 Laf 的存储 bucket 中,也可以这样调用我们创建的 upload-file 云函数来上传文件并获取到 URL。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第11张图片

总之我们现在有了一个音频文件的 URL 想要转成文字需要继续调用我们的 baidu-api 云函数。

调用示例

在云函数 baidu-api 右侧的调试面板选择 POST 请求方法,body 中传入以下参数后点击运行。


这里的 fileUrl 改成你需要转文字的音频 URL
{
  "type": "createVoiceToText",
  "param": {
    "fileUrl": "https://cofxat-store-file.oss.laf.run/TextToVoice/344762599164.mp3",
    "fileName": "TempFiles/96bcdd36-373a-4031-b843-33d45c17dc03.mp3",
    "fileType": "audio/mpeg",
    "fileSize": 1001504
  }
}

同样的我们运行之后会得到一个任务 ID 我们需要根据这个任务的 ID 来查询转换之后的结果。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第12张图片

音频文件转文本查询,调用云函数 baidu-api传入以下参数。


这里的 taskIds 换成你刚刚得到的任务 ID
{
    "type": "searchVoiceToText",
    "param": {
        "taskIds": ["6498082dd9ab330001cf1f9f"]
    }
}

调用之后成功得到文本。

Laf 实现语音文本相互转换,直接让 GPT 开口说话_第13张图片
Ok! 至此我们用 Laf 实现了文本和音频的自由转换!

关于 Laf

Laf 是一款为所有开发者打造的集函数、数据库、存储为一体的云开发平台,助你像写博客一样写代码,随时随地发布上线应用!3 分钟上线 ChatGPT 应用!

GitHub:https://github.com/labring/laf

官网(国内):https://laf.run

官网(海外):https://laf.dev

开发者论坛:https://forum.laf.run
sealos 以kubernetes为内核的云操作系统发行版,让云原生简单普及

laf 写代码像写博客一样简单,什么docker kubernetes统统不关心,我只关心写业务!

你可能感兴趣的:(云计算)