从图片到语音: Kimi 视觉模型与火山引擎的完美结合!

引言

今天我们来实现这么一个功能, 用户端上传一张图片 + 一段提示词(要求根据图片怎么输出内容), 转换成一段语音。

今天我们会需要:

  1. 使用 Kimi 视觉模型, 完成图片转文字的功能
  2. 使用火山引擎语音合成接口, 来实现文本转语音的功能

最后效果如下, 代码仓库点 这里

一、Kimi 视觉模型研究

这里我们研究下 Kimi 视觉模型的调用...

1.1 创建 API Key

是的既然我们是要调用人家的 API 那么必然需要鉴权的, 基本都是要去创建一个私人的 API Key...

  1. 登录注册 Kimi 开放平台 账号, 并进入用户中心
  1. 创建 API Key

1.2 视觉模型调用

其实 Kimi 中视觉模型的使用和其他模型所调用的接口其实是同一个, 只需要在接口中选定指定模型即可, 具体模型信息可查阅 官方文档

在开始前我们先找个免费的 图片转 base64 工具 将任意一张图转为 base64 因为我们后面图片数据需要通过 base64 形式传给大模型, 先提前把数据准备好

下面参考 参考视觉模型文档 我们先尝试在 Chrome 控制台调用下接口, 进行一个简单的测试即可

js 复制代码
fetch('https://api.moonshot.cn/v1/chat/completions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer {你的 API Key}`, // 鉴权, 填写你的 API Key
  },
  body: JSON.stringify({
    stream: false, // 关闭流式输出
    model: 'moonshot-v1-8k-vision-preview', // 设置模型
    messages: [
      {
        role: 'user',
        content: [
          { type: 'text', text: '请描述图片的内容。' }, // 提示词(要根据图片输出啥)
          {
            type: 'image_url', // 设置消息类型为图片
            image_url: { 
              url: 'data:image/png;base64,iVBO*********************==', // 这里的 url 是 base64 的图片数据
            },
          },
        ],
      },
    ],
  }),
});

下图是, 在控制台请求接口情况, 请求响应中 choices[0].message.content 就是我们所需要的内容

上面是我们是使用原生 API fetch 来调用 Kimi 大模型, 该方式即可在 Web 端使用在 Node 中一样适用, 下面是使用第三方库 openai 调用代码, 需要注意的是该方式只适用于 Node 端:

js 复制代码
import OpenAI from 'openai';

const client = new OpenAI({
  apiKey: '你的 API Key', // 鉴权, 填写你的 API Key
  baseURL: 'https://api.moonshot.cn/v1/',
});

const completion = await client.chat.completions.create({
  stream: false, // 关闭流式输出
  model: 'moonshot-v1-8k-vision-preview', // 设置模型
  messages: [
    {
      role: 'user',
      content: [
        { type: 'text', text: '请描述图片的内容。' }, // 提示词(要根据图片输出啥)
        {
          type: 'image_url', // 设置消息类型为图片
          image_url: {
            url: 'data:image/png;base64,iVBOR**********F1KsBcwAAAABJRU5ErkJggg==', // 这里的 url 是 base64 的图片数据
          },
        },
      ],
    },
  ],
});

console.log(completion.choices[0].message.content);

上面上面执行结果如下:

二、火山引擎语音合成接口研究

上面例子演示了如何将图转文字, 下面我们就需要将文字转语音了, 这里我们使用的是 火山引擎的接口...

2.1 开通服务

访问 火山引擎官网 搜索 「语音合成」, 在下拉选项「语音合成」一栏直接选择「控制台」(第一次使用可能还需要进行实名认证)

创建一个新的应用, 服务这栏选择「大模型语音合成」和「语音合成」

创建完毕后, 左侧菜单切换到 API 服务中心 > 音频生成大模型 > 语音合成大模型 右侧可以看到 服务详情音色详情服务接口认证信息 等内容。这里还需要先开通下服务(图中我是已经开通的状态), 然后默认是没有音色的, 这里还需要购买下音色(不要钱)

最后需要注意的是记得将服务接口认证信息里的 APP IDAccess Token 保存下来, 我们后续调用需要用到:

至此火山引擎注册和开通服务部分已经完成

2.2 调用调试

下面我们根据 大模型语音合成API 文档 来试着调用下, 需要注意的是前端直接调用火山引擎服务会有跨域问题, 所以下面代码我是在 Node 中运行的

js 复制代码
const res = await fetch('https://openspeech.bytedance.com/api/v1/tts', { 
  method: 'POST', 
  headers: {
    Authorization: 'Bearer;{你的 Access Token}',  // 鉴权信息
  }, 
  body: JSON.stringify({
    app: {                            // 应用信息
      token: '{你的 Access Token}',   // 语音合成大模型的 Access Token
      appid: '{你的 App ID}',         // 语音合成大模型的应用 ID
      cluster: 'volcano_tts',        // 业务集群: 不用管, 固定填写该值就行
    },
    user: {            // 用户信息
      uid: 'bearbobo', // 用户标识: 可传任意非空字符串, 传入值可以通过服务端日志追溯
    },
    audio: {                                          // 音频相关配置
      encoding: 'ogg_opus',                           // 音频编码格式: wav / pcm / ogg_opus / mp3,默认为 pcm 注意的是 wav 不支持流式
      voice_type: 'zh_female_wanqudashu_moon_bigtts', // 音色类型: 可取值查阅音色详情列表 Voice_type 一栏
      rate: 24000,                                    // 音频采样率: 默认为 24000, 可选8000,16000
      speed_ratio: 1.0,                               // 语速: 取值范围为 [0.8,2], 默认为 1 通常保留一位小数即可
      loudness_ratio: 1.0,                            // 音量: 1.0 是正常音量。
      emotion: 'happy',                               // 音色情感: 大模型根据它来调整语音的情感色彩 happy 表示欢快, 语音可能会更轻松愉快, 语气上会有更多的高低起伏
    },
    request: {                                        // 请求相关配置
      reqid: Math.random().toString(36).substring(7), // 请求标识: 需要保证每次调用传入值唯一, 建议使用 UUID
      text: '火山引擎语音合成开放接口测试',                // 文本: 合成语音的文本, 长度限制 1024 字节(UTF-8 编码)
      operation: 'query',                             // 操作: query(非流式, http 只能 query)/submit(流式)
      silence_duration: '125',                        // 句尾静音: 设置该参数可在句尾增加静音时长, 范围 0 ~ 30000ms
    },
  }), 
});

const { data } = await res.json();

console.log(data); // 打印出 base64 位音频数据

最后, 我们使用 Node 执行上面代码将会在控制台输出数据, 也就是音频的 Base64 编码数据

三、开始

好了, 核心需要的功能点我们上面都已经演示了, 下面我们就需要将其串起来....

项目情况说明:

  1. 项目是 NextJS 项目, 这里我是直接通过官方脚手架搭建起来的, 关于 NextJS 搭建可以看我之前写的这篇文章 Next 项目搭建指南(写着写着就 3 万多字了~~~)
  2. 组件的话我这边用的是 HeroUI, 其实就是之前的 NextUI 只是它后面改名了
  3. 样式这边使用了 Tailwind

3.1 接口实现

考虑到火山引擎接口在 Web 端直接调用是会跨域的, 同时两个大模型调用也是要进行鉴权的, API Key 肯定不能暴露给用户, 所以这里我们干脆直接写一个接口:

  • 该接口负责将这两个大模型直接串起来
  • 接口参数是图片的 Base64 数据
  • 而最终响应内容为音频的 Base64 数据
  1. 新增一个 POST 接口: api/img2audio
  1. 我们简单在控制台尝试调用下接口, 看下能不能调得通, 如下图所示能正常跑通就行
  1. 下面直接看代码:
  • 定义了一个 POST 接口, 接收两个参数分别是 imageprompt
  • 参数 image 是图片的 base64, 而 prompt 则描述了我们要根据图片输出什么样的音频内容
  • 接口最终返回一个 data 即音频数据
  • 代码中 img2text 函数调用 Kimi 视觉模型, 目的是将给定的图片数据转为文本
  • 代码中 text2audio 函数调用头条语音合成接口, 目的是将给定的文本内容转为音频数据
js 复制代码
// src/app/api/img2audio/route.ts
import OpenAI from 'openai';

const client = new OpenAI({
  apiKey: process.env.KIMI_API_KEY, // 鉴权, 填写你的 API Key
  baseURL: 'https://api.moonshot.cn/v1/',
});

// 图转文字: 调用 Kimi 视觉模型
const img2text = async (image: string, prompt: string): Promise<string> => {
  const completion = await client.chat.completions.create({
    stream: false, // 关闭流式输出
    model: 'moonshot-v1-8k-vision-preview', // 设置模型
    messages: [
      {
        role: 'user',
        content: [
          { type: 'text', text: prompt }, // 提示词(要根据图片输出啥)
          {
            type: 'image_url', // 设置消息类型为图片
            image_url: { url: image }, // 这里的 url 是 base64 的图片数据
          },
        ],
      },
    ],
  });

  return completion.choices[0].message.content || '';
};

// 文字转音频: 调用火山引擎语音接口
const text2audio = async (text: string): Promise<string> => {
  const res = await fetch('https://openspeech.bytedance.com/api/v1/tts', {
    method: 'POST',
    headers: {
      Authorization: `Bearer;${process.env.VOLCENGINE_ACCESS_TOKEN}`, // 鉴权信息
    },
    body: JSON.stringify({
      // 应用信息
      app: {
        token: process.env.VOLCENGINE_ACCESS_TOKEN, // 语音合成大模型的 Access Token
        appid: process.env.VOLCENGINE_APP_ID, // 语音合成大模型的应用 ID
        cluster: 'volcano_tts', // 业务集群: 不用管, 固定填写该值就行
      },
      // 用户信息
      user: {
        uid: 'bearbobo', // 用户标识: 可传任意非空字符串, 传入值可以通过服务端日志追溯
      },
      // 音频相关配置
      audio: {
        encoding: 'ogg_opus', // 音频编码格式: wav / pcm / ogg_opus / mp3,默认为 pcm 注意的是 wav 不支持流式
        voice_type: 'zh_female_wanqudashu_moon_bigtts', // 音色类型: 可取值查阅音色详情列表 Voice_type 一栏
        rate: 24000, // 音频采样率: 默认为 24000, 可选8000,16000
        speed_ratio: 1.0, // 语速: 取值范围为 [0.8,2], 默认为 1 通常保留一位小数即可
        loudness_ratio: 1.0, // 音量: 1.0 是正常音量。
        emotion: 'happy', // 音色情感: 大模型根据它来调整语音的情感色彩 happy 表示欢快, 语音可能会更轻松愉快, 语气上会有更多的高低起伏
      },
      // 请求相关配置
      request: {
        text, // 文本: 合成语音的文本, 长度限制 1024 字节(UTF-8 编码)
        reqid: Math.random().toString(36).substring(7), // 请求标识: 需要保证每次调用传入值唯一, 建议使用 UUID
        operation: 'query', // 操作: query(非流式, http 只能 query)/submit(流式)
        silence_duration: '125', // 句尾静音: 设置该参数可在句尾增加静音时长, 范围 0 ~ 30000ms
      },
    }),
  });

  const { data } = await res.json();
  return data;
};

export async function POST(req: Request) {
  const { image, prompt = '请描述图片的内容。' } = await req.json();

  const text = await img2text(image, prompt);
  const audio = await text2audio(text);

  return new Response(JSON.stringify({ data: audio, code: 0, message: 'success' }));
}

下面是接口调用测试:

3.2 页面交互实现

有了接口我们就开始绘制页面:

  • 页面界面很简单就一个图片上传、一个文本输入框, 一个生成按钮
  • 交互: 上传图片、输入文本内容后, 点击生成按钮, 出来一个 loading
  • 生成成功页面则展示一个音频播放控件, 点击播放/暂停音频的播放

下面我们新增一个页面 /img2audio

然后先进行一个基本的页面布局:

js 复制代码
// src/app/img2audio/page.tsx
'use client';
import { useCallback } from 'react';
import { Textarea, Button } from '@heroui/react';
import Icon from '@/components/Icon/index';

const Page = () => {
  const handleSend = useCallback(() => {}, []);

  return (
    <div className="flex h-screen w-screen items-center justify-center overflow-y-auto bg-[#131313]">
      <div className="relative flex w-1/2">
        <input
          type="file"
          id="img2audio-upload"
          className="size-0 opacity-0"
        />
        <label
          htmlFor="img2audio-upload"
          className="mr-3 flex size-[176px] flex-none cursor-pointer items-center justify-center rounded-xl border-2 border-dotted border-white/10 bg-[#1d1d1d]">
          <Icon
            name="icon-upload"
            className="text-6xl text-white/10 transition-all hover:text-white/50"
          />
        </label>
        <Textarea
          minRows={8}
          maxRows={8}
          placeholder="需要根据图片输出什么?"
          classNames={{ inputWrapper: 'flex-1 bg-[#1d1d1d]' }}
        />
        <Button
          isIconOnly
          size="sm"
          radius="full"
          color="primary"
          className="absolute bottom-4 right-4"
          onPress={handleSend}>
          <Icon
            name="icon-arrdown"
            className="text-xl text-white/80"
          />
        </Button>
      </div>
    </div>
  );
};

export default Page;

到此上面代码页面效果如下:

下面我们先来处理图片, 我们需要将上传的图片转为 Base64 存储起来, 同时希望对应上传控件显示未具体的图片:

  • 新增 imgData 状态, 用于存储图片数据
  • 函数 handleUpload 监听控件上传文件的变更, 函数内部使用 FileReader 来读取图片 Base64 数据
  • 最后通过 imgData 状态, 来展示已上传的图片
diff 复制代码
// src/app/img2audio/page.tsx
...
const Page = () => {
+ const [imgData, setImgData] = useState<string | null>(null);

  const handleSend = useCallback(() => {}, []);

+ const handleUpload = useCallback(async (e: ChangeEvent) => {
+   const file = (e.target as HTMLInputElement).files?.[0];
+   if (!file) {
+     return;
+   }
+
+   // 读取图片文件
+   const reader = new FileReader();
+   reader.readAsDataURL(file);
+   reader.onload = () => {
+     setImgData(reader.result as string);
+   };
+ }, []);

  return (
    <div className="flex h-screen w-screen items-center justify-center overflow-y-auto bg-[#131313]">
      <div className="relative flex w-1/2">
        ....
        <label
          htmlFor="img2audio-upload"
          className="mr-3 flex size-[176px] flex-none cursor-pointer items-center justify-center rounded-xl border-2 border-dotted border-white/10 bg-[#1d1d1d]">
+         {imgData ? (
+           <Image
+             width={160}
+             height={160}
+             src={imgData}
+             alt="upload img"
+           />
+         ) : (
+           <Icon
+             name="icon-upload"
+             className="text-6xl text-white/10 transition-all hover:text-white/50"
+           />
+         )}
        </label>
        ....
      </div>
    </div>
  );
};

export default Page;

目前效果如下:

下面我们在新增一个 prompt 和输入框绑定, 并实时获取输入框输入的内容:

diff 复制代码
// src/app/img2audio/page.tsx
const Page = () => {
  const [imgData, setImgData] = useState<string | null>(null);
+ const [prompt, setPrompt] = useState<string>('');
  ....
  return (
    <div className="flex h-screen w-screen items-center justify-center overflow-y-auto bg-[#131313]">
      <div className="relative flex w-1/2">
        ...
        <Textarea
          minRows={8}
          maxRows={8}
+         value={prompt}
+         onValueChange={setPrompt}
          placeholder="需要根据图片输出什么?"
          classNames={{ inputWrapper: 'flex-1 bg-[#1d1d1d]' }}
        />
        ....
      </div>
    </div>
  );
};

export default Page;

这里我们已经能够拿到接口请求所需数据, 下面我们继续开发:

  • 当用户点击发送按钮, 则向后端请求接口, 而接口最终返回一个 Base64 的音频数据
  • 最终这里我们可以通过 createBlobURL 函数将 Base64 编码的数据转换成二进制对象并生成一个 URL, 最终 URL 将作为 audio 标签的 src
diff 复制代码
// src/app/img2audio/page.tsx
....

+ // 将 Base64 的音频数据转为 URL
+ const createBlobURL = (base64AudioData: string): string => {
+   const byteArrays = [];
+   const byteCharacters = atob(base64AudioData);
+
+   for (let offset = 0; offset < byteCharacters.length; offset++) {
+     const byteArray = byteCharacters.charCodeAt(offset);
+     byteArrays.push(byteArray);
+   }
+
+   const blob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });
+   return URL.createObjectURL(blob); // 创建一个临时 URL 供音频播放
+ }

const Page = () => {
  const [imgData, setImgData] = useState<string | null>(null);
  const [prompt, setPrompt] = useState<string>('');
+ const [audioUrl, setAudioUrl] = useState<string | null>(null);
+ const [isLoading, setIsLoading] = useState<boolean>(false);

+ const handleSend = useCallback(async () => {
+   setIsLoading(true);
+
+   const res = await fetch('/api/img2audio', {
+     method: 'POST',
+     body: JSON.stringify({
+       prompt,
+       image: imgData,
+     }),
+   });
+   const { data } = await res.json();
+
+   setAudioUrl(createBlobURL(data));
+   setIsLoading(false);
+ }, [imgData, prompt]);

  ....

  return (
    <div className="flex h-screen w-screen flex-col items-center justify-center overflow-y-auto bg-[#131313]">
+     {isLoading || audioUrl ? (
+       <div className="flex w-1/2 justify-center pb-5">
+         {isLoading ? (
+           <Skeleton className="h-[54px] w-3/5 rounded-full" />
+         ) : (
+           <audio
+             controls
+             src={audioUrl!}
+             className="w-3/5"
+           />
+         )}
+       </div>
+     ) : null}
      <div className="relative flex w-1/2">
        ....
      </div>
    </div>
  );
};

export default Page;

到此核心功能算是完成了, 整体的流程都是通了的, 当然还有很多边界处理什么的我都忽略不计咯, 毕竟只是简单的 DEMO 下面看一眼最终效果吧!

四、参考

相关推荐
Carlos_sam29 分钟前
OpenLayers:封装一个自定义罗盘控件
前端·javascript
前端南玖38 分钟前
深入Vue3响应式:手写实现reactive与ref
前端·javascript·vue.js
wordbaby1 小时前
React Router 双重加载器机制:服务端 loader 与客户端 clientLoader 完整解析
前端·react.js
渡歌学习笔记1 小时前
AIGC短剧炫酷运镜来了!20种AI运镜高阶版运镜方法!附详细提示词!
aigc
itslife1 小时前
Fiber 架构
前端·react.js
3Katrina1 小时前
妈妈再也不用担心我的课设了---Vibe Coding帮你实现期末课设!
前端·后端·设计
hubber1 小时前
一次 SPA 架构下的性能优化实践
前端
可乐只喝可乐2 小时前
从0到1构建一个Agent智能体
前端·typescript·agent
Muxxi2 小时前
shopify模板开发
前端
Yueyanc2 小时前
LobeHub桌面应用的IPC通信方案解析
前端·javascript