引言
今天我们来实现这么一个功能, 用户端上传一张图片 + 一段提示词(要求根据图片怎么输出内容), 转换成一段语音。
今天我们会需要:
- 使用
Kimi
视觉模型, 完成图片转文字的功能 - 使用火山引擎语音合成接口, 来实现文本转语音的功能
最后效果如下, 代码仓库点 这里
一、Kimi 视觉模型研究
这里我们研究下 Kimi
视觉模型的调用...
1.1 创建 API Key
是的既然我们是要调用人家的 API
那么必然需要鉴权的, 基本都是要去创建一个私人的 API Key
...
- 登录注册 Kimi 开放平台 账号, 并进入用户中心
1.2 视觉模型调用
其实 Kimi
中视觉模型的使用和其他模型所调用的接口其实是同一个, 只需要在接口中选定指定模型即可, 具体模型信息可查阅 官方文档
在开始前我们先找个免费的 图片转 base64 工具 将任意一张图转为 base64
因为我们后面图片数据需要通过 base64
形式传给大模型, 先提前把数据准备好
下面参考 参考视觉模型文档 我们先尝试在 Chrome
控制台调用下接口, 进行一个简单的测试即可
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
端:
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 ID
和 Access Token
保存下来, 我们后续调用需要用到:
至此火山引擎注册和开通服务部分已经完成
2.2 调用调试
下面我们根据 大模型语音合成API 文档 来试着调用下, 需要注意的是前端直接调用火山引擎服务会有跨域问题, 所以下面代码我是在 Node
中运行的
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
编码数据
三、开始
好了, 核心需要的功能点我们上面都已经演示了, 下面我们就需要将其串起来....
项目情况说明:
- 项目是
NextJS
项目, 这里我是直接通过官方脚手架搭建起来的, 关于NextJS
搭建可以看我之前写的这篇文章 Next 项目搭建指南(写着写着就 3 万多字了~~~) - 组件的话我这边用的是
HeroUI
, 其实就是之前的NextUI
只是它后面改名了 - 样式这边使用了
Tailwind
3.1 接口实现
考虑到火山引擎接口在 Web
端直接调用是会跨域的, 同时两个大模型调用也是要进行鉴权的, API Key
肯定不能暴露给用户, 所以这里我们干脆直接写一个接口:
- 该接口负责将这两个大模型直接串起来
- 接口参数是图片的
Base64
数据 - 而最终响应内容为音频的
Base64
数据
- 新增一个
POST
接口:api/img2audio
- 我们简单在控制台尝试调用下接口, 看下能不能调得通, 如下图所示能正常跑通就行
- 下面直接看代码:
- 定义了一个
POST
接口, 接收两个参数分别是image
和prompt
- 参数
image
是图片的base64
, 而prompt
则描述了我们要根据图片输出什么样的音频内容 - 接口最终返回一个
data
即音频数据 - 代码中
img2text
函数调用Kimi
视觉模型, 目的是将给定的图片数据转为文本 - 代码中
text2audio
函数调用头条语音合成接口, 目的是将给定的文本内容转为音频数据
// 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
然后先进行一个基本的页面布局:
// 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
状态, 来展示已上传的图片
// 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
和输入框绑定, 并实时获取输入框输入的内容:
// 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
// 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
下面看一眼最终效果吧!