掘金 人工智能 07月07日 21:34
从图片到语音:我是如何用两大模型API打造沉浸式英语学习工具的
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

该项目整合了月之暗面视觉大模型和字节TTS语音合成API,构建了“图片语义理解-语音知识输出”的学习闭环。用户上传图片后,项目分析图片内容并生成英文单词、例句和解释,同时支持语音跟读。前端采用组件化设计,结合Prompt工程和环境变量管理,优化交互体验,为用户提供流畅的学习体验,验证了多模态API在前端落地的可行性。

🖼️ **图片预览与上传**: 项目使用React的useState和JavaScript的FileReader API实现图片上传和预览功能。通过FileReader将图片转换为Data URL,并使用可选链操作符和Promise优化代码,提升用户体验。

🧠 **视觉语义解析**: 通过调用月之暗面视觉大模型API,项目能够分析用户上传的图片,提取核心语义,生成对应的英文单词、例句及解释。项目通过Promote设计限定了大模型的回答格式为JSON,方便后续数据处理。

🗣️ **语音合成**: 项目利用字节TTS语音大模型API,将视觉大模型输出的文字内容转化为自然语音,支持根据语义情感调整语调。通过处理TTS返回的Base64格式音频数据,将其转换为Blob+URL格式,方便播放。

💡 **前端与AI协同**: 项目深度融合前端技术与AI,前端作为人机交互枢纽,将用户行为转化为AI可理解的语言,例如图片转Base64、Prompt精准定制。同时,项目使用了ES6可选链操作符、CSS线性渐变等技术优化性能和用户体验。

🛠️ **组件化开发**: 项目采用组件化设计,将不同功能封装在独立的组件中,例如PictureCard组件专注于图片上传和展示。组件之间通过props进行数据传递和通信,确保了数据的流向清晰,增强了代码的可读性和可维护性。

前言

如今,集成多模态大模型 API 的前端项目模式愈发普遍,从电商平台调用图像识别 API实现商品检索,到教育应用接入语音合成 API 优化交互体验,各类场景都在通过 「大模型能力嫁接」 提升服务价值。这类项目的核心逻辑在于打破技术壁垒,以 API 调用实现视觉、语言等多模态能力的协同 —— 而本项目正是这一趋势的实践延伸,通过整合 「月之暗面」图像分析与「字节 TTS」语音合成 API,构建了 「图片语义理解 - 语音知识输出」 的学习闭环

一、项目效果展示

项目链接github地址github.com/Objecteee/-…

该项目可以分析用户上传的图片总结出图片中事物对应的英文单词,并给出一些例句和问题,而且支持语音跟读,接下来我会详细介绍该功能的实现

注:是有声音的

二、项目整体思路

    视觉语义解析调用「月之暗面」视觉大模型 API,对用户上传图片进行多模态分析,不仅识别物体、场景等视觉元素,还提炼核心语义,生成对应的英文单词、情景化例句及语法解释语言模态转换借助「字节 TTS」语音大模型 API,将视觉大模型输出的文字内容转化为自然语音,支持根据语义情感调整语调、重音(如描述动态场景时语速加快);跨模态协同通过前端逻辑串联两大模型 API,使视觉理解结果直接驱动语音合成参数,形成 “图片内容→大模型语义生成→大模型语音演绎” 的全链路智能交互,最终以 “看图学英文” 的形式实现技术落地。

三、项目的功能实现

1.图片预览与上传功能

用户可以上传图片并可以实时预览

const [imgPreview, setImgPreview] = useState('https://res.bearbobo.com/resource/upload/W44yyxvl/upload-ih56twxirei.png')  const uploadImgData=(e)=>{    const file = (e.target).files?.[0];        if (!file) { return; }        return new Promise((resolve, reject) => {            const reader = new FileReader();            reader.readAsDataURL(file);            reader.onload = () => {                const data = reader.result;                setImgPreview(data);                uploadImg(data);                resolve(data);            }            reader.onerror = (error) => { reject(error); };        })  }

这段代码主要使用了React的useState和JavaScript的FileReaderAPI。

首先使用了useState定义了响应式变量imgPreview,用于存储图片数据,默认的图片样式为res.bearbobo.com/resource/up… ,即下面的样式

接下来便是使用JavaScript的FileReader API,实现图片数据的存储

1.es6新特性的使用——可选链操作符(?.)

使用e.target.files?.[0]中的可选链操作符(?.),即使files为null也不会报错这种写法比传统的e.target.files && e.target.files[0]更加简洁

2.Promise的巧妙运用

将整个读取过程封装在Promise中,使函数可以这样使用:

handleImageUpload(event)  .then(data => console.log('处理成功', data))  .catch(err => console.error('出错了', err))

文件的读取是一个大任务,我们使用异步操作处理

3.FileReader的工作机制

readAsDataURL方法会将文件转换为Data URL格式这种格式可以直接赋值给img标签的src属性实现预览示例Data URL:data:image/png;base64,iVBORw0KGgoAAAAN...

4.双重数据流处理,更好的用户体验

一方面通过setImgPreview更新界面,另一方面通过uploadToServer准备上传,这种设计实现了预览和上传的并行处理,对用户更友好

5.完善的错误处理

通过Promise的reject机制传递错误,确保读取过程中的任何错误都能被捕获处理

2.图片解析与月之暗面Promote设计

const userPrompt = `分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇。  返回JSON数据:  {   "image_discription": "图片描述",   "representative_word": "图片代表的英文单词",   "example_sentence": "结合英文单词和图片描述,给出一个简单的例句",   "explaination": "结合图片解释英文单词,段落以Look at...开头,将段落分句,每一句单独一行,解释的最后给一个日常生活有关的问句",   "explaination_replys": ["根据explaination给出的回复1", "根据explaination给出的回复2"]  }`;  // 上传图片的状态   const [word, setWord] = useState('请上传图片');  // 例句  const [sentence, setSentence] = useState('')  // 解释  const [explainations, setExplainations] = useState([]);  const [expReply, setExpReply] = useState([])  const [imgPreview, setImgPreview] = useState('https://res.bearbobo.com/resource/upload/W44yyxvl/upload-ih56twxirei.png')  const uploadImg = async (imageData) => {    setImgPreview(imageData);    const endpoint = 'https://api.moonshot.cn/v1/chat/completions';    const headers = {       'Content-Type': 'application/json',       'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`     };    setWord('分析中...');    const response = await fetch(endpoint, {      method: 'POST',      headers: headers,      body: JSON.stringify({        model: 'moonshot-v1-8k-vision-preview',        messages: [           {             role: 'user',             content: [              {                 type: "image_url",                 image_url: { "url": imageData, },               },               { type: "text", text: userPrompt,               }]             }],            stream: false        })    })    const data = await response.json();    const replyData = JSON.parse(data.choices[0].message.content);    // console.log(replyData);    setWord(replyData.representative_word);    setSentence(replyData.example_sentence);    setExplainations(replyData.explaination.split('\n'))    setExpReply(replyData.explaination_replys);  }

这段代码实现了调用月之暗面API分析图片的工作,接下来我们来分析一下这段代码的实现

这段代码的最重要的逻辑便是Promote的设计,限制了大模型回答为JSON的格式,以便于使用JSON.stringify将大模型的回答转为JSON格式,某种意义上实现了回答->文本->JSON->数据的转变,这便是调用API的核心思想

之后我们依照大模型返回的数据格式解析出我们需要的格数据即可

肥肠有石粒!

3.调用字节tts的API

const getAudioUrl = (base64Data) => {  // 创建一个数组来存储字节数据    var byteArrays = [];    // 使用atob()将Base64编码的字符串解码为原始二进制字符串    // atob: ASCII to Binary    var byteCharacters = atob(base64Data);    // 遍历解码后的二进制字符串的每个字符    for (var offset = 0; offset < byteCharacters.length; offset++) {        // 将每个字符转换为其ASCII码值(0-255之间的数字)        var byteArray = byteCharacters.charCodeAt(offset);        // 将ASCII码值添加到字节数组中        byteArrays.push(byteArray);    }    // 创建一个Blob对象    // new Uint8Array(byteArrays)将普通数组转换为8位无符号整数数组    // { type: 'audio/mp3' } 指定Blob的MIME类型为MP3音频        var blob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });     // 使用URL.createObjectURL创建一个临时的URL    // 这个URL可以用于<audio>标签的src属性    // 这个URL在当前页面/会话有效,页面关闭后会自动释放    // 创建一个临时 URL 供音频播放    return URL.createObjectURL(blob);}export const generateAudio= async(text)=>{     const token = import.meta.env.VITE_AUDIO_ACCESS_TOKEN;    const appId = import.meta.env.VITE_AUDIO_APP_ID;    const clusterId = import.meta.env.VITE_AUDIO_CLUSTER_ID;    const voiceName = import.meta.env.VITE_AUDIO_VOICE_NAME;    const endpoint = '/tts/api/v1/tts';    const headers = {        'Content-Type': 'application/json',        Authorization: `Bearer;${token}`,    };    // 请求体    const payload = {        app: {            appid: appId,            token,            cluster: clusterId,        },        user: {            uid: 'bearbobo',        },        audio: {            voice_type: voiceName,            encoding: 'ogg_opus',            compression_rate: 1,            rate: 24000,            speed_ratio: 1.0,            volume_ratio: 1.0,            pitch_ratio: 1.0,            emotion: 'happy',            // language: 'cn',        },        request: {            reqid: Math.random().toString(36).substring(7),            text,            text_type: 'plain',            operation: 'query',            silence_duration: '125',            with_frontend: '1',            frontend_type: 'unitTson',            pure_english_opt: '1',        },    };    const res=await fetch(endpoint, {        method: 'POST',        headers,        body: JSON.stringify(payload),    });    const data=await res.json();    console.log(data,'////////');    const url=getAudioUrl(data.data)    return url;}

其实我认为这段tts的代码虽然比较长,但是是比较简单的,毕竟返回的数据很简单,而且不需要我们自己设计Promote

而各种的看似复杂没有逻辑的参数,大家去查文档就可以了,其实真正的重头戏是下面的处理tts返回的数据

4.处理字节tts大模型返回数据

const getAudioUrl = (base64Data) => {  // 创建一个数组来存储字节数据    var byteArrays = [];    // 使用atob()将Base64编码的字符串解码为原始二进制字符串    // atob: ASCII to Binary    var byteCharacters = atob(base64Data);    // 遍历解码后的二进制字符串的每个字符    for (var offset = 0; offset < byteCharacters.length; offset++) {        // 将每个字符转换为其ASCII码值(0-255之间的数字)        var byteArray = byteCharacters.charCodeAt(offset);        // 将ASCII码值添加到字节数组中        byteArrays.push(byteArray);    }    // 创建一个Blob对象    // new Uint8Array(byteArrays)将普通数组转换为8位无符号整数数组    // { type: 'audio/mp3' } 指定Blob的MIME类型为MP3音频        var blob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });     // 使用URL.createObjectURL创建一个临时的URL    // 这个URL可以用于<audio>标签的src属性    // 这个URL在当前页面/会话有效,页面关闭后会自动释放    // 创建一个临时 URL 供音频播放    return URL.createObjectURL(blob);}

tts返回的数据是音频的Base64格式,我们需要将其转化为Bolb+URL的格式,方便我们继续使用,至于为什么有这样做,可以看我之前发的也一篇博客,里面有详尽的解释AI时代,前端如何处理大模型返回的多模态数据?- 掘金

这段代码的主要功能是将Base64编码的音频数据转换为可直接播放的临时URL。首先,它使用atob()函数将Base64字符串解码为二进制数据,这个过程中每个字符会被转换为对应的ASCII码值(0-255之间的数字),这些值被逐个存入字节数组中。

接着,代码通过new Uint8Array()将普通数组转换为8位无符号整数数组,确保二进制数据的格式正确。然后,利用 new Blob()将这些二进制数据封装成一个MP3格式的Blob对象,并指定MIME类型为audio/mp3

最后,通过URL.createObjectURL()生成一个临时URL,该URL可以直接用于<audio>标签的src属性,从而实现在网页中播放这段音频。

需要注意的是,这个URL仅在当前页面会话中有效,页面关闭后会自动释放内存。

四、项目亮点

1.前端+AI

本项目中 “前端 + AI” 的深度协同,实则是当下技术融合浪潮的缩影。前端作为人机交互的枢纽,将用户行为转化为 AI 可理解的语言 —— 图片转 Base64、Prompt 精准定制,不仅适配月之暗面等模型的调用需求,更暗合了 “交互层智能化” 的行业趋势。

LLM 发展倒逼产品升级智能体验,前端作为交互层责无旁贷。集成 AI 可实现智能搜索等功能,重塑工程师角色,是打造智能产品的关键。

2.es6新特性的使用

 const file = (e.target).files?.[0]; if (!file) { return; }

这段代码中 const file = (e.target).files?.[0]; 使用了 ES6 的两个重要特性:

    可选链操作符 (Optional Chaining Operator ?.)数组元素的可选访问

可选链操作符 (?.)

可选链操作符 ?. 允许你安全地访问嵌套的对象属性,而无需明确验证每个引用是否有效。如果引用是 null 或 undefined,表达式会短路并返回 undefined,而不是抛出错误。

传统写法:

const file = e.target && e.target.files && e.target.files[0];

ES6 可选链写法:

const file = e.target?.files?.[0];

代码解析

e.target?.files?.[0] 的解析过程:

    e.target? - 首先检查 e.target 是否存在(不为 null 或 undefined

      如果不存在,整个表达式返回 undefined如果存在,继续访问 .files

    .files? - 然后检查 files 属性是否存在

      如果不存在,整个表达式返回 undefined如果存在,继续访问 [0]

    [0] - 最后访问数组的第一个元素

3.背景的性能优化

使用CSS线性渐变(linear-gradient)作为背景,相比传统的图片URL背景(如background: url("image.jpg")),在性能、灵活性和开发效率上都有明显优势。

线性渐变背景—— 无HTTP请求:完全由CSS生成,不需要额外下载图片文件,减少网络请求。

URL背景—— 需要HTTP请求:必须下载图片文件,增加网络开销,尤其是在慢速连接下。可能阻塞渲染:大图片或慢网络时,背景图未加载完成可能导致布局延迟(除非优化loading="lazy")。

4.组件拆分

该项目在组件拆分上展现出清晰的逻辑和良好的可维护性。整体遵循组件化开发原则,将不同功能封装在独立的组件中根组件App负责整体的状态管理、API 请求和页面结构搭建,它持有上传图片、单词、例句等多个状态,并通过fetch方法向 API 发送请求以获取图片分析结果。这种设计使得应用的核心逻辑集中在根组件,便于管理和维护

子组件PictureCard专注于图片上传和展示功能。它接收来自父组件AppwordaudiouploadImg等属性,处理图片选择和预览,并在用户上传图片时调用父组件传递的uploadImg函数。通过这种方式,子组件仅关注自身的具体功能,实现了功能的解耦,提高了代码的复用性和可测试性组件之间通过props进行数据传递和通信,确保了数据的流向清晰,增强了代码的可读性和可维护性

5.html5的文件对象

这是个亮点,因为这是HTML5的新特性,而且操作方便,前文有解释,这里不再赘述

6.大模型的Promote设计

const userPrompt = `分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇。  返回JSON数据:  {   "image_discription": "图片描述",   "representative_word": "图片代表的英文单词",   "example_sentence": "结合英文单词和图片描述,给出一个简单的例句",   "explaination": "结合图片解释英文单词,段落以Look at...开头,将段落分句,每一句单独一行,解释的最后给一个日常生活有关的问句",   "explaination_replys": ["根据explaination给出的回复1", "根据explaination给出的回复2"]  }`;

本项目对|月之暗面|大模型的Promote很有考究。 通过上述的Promote设计,限定了大模型的回答的格式,便于将文本转化为JSON数据,以便于后续对该数据的应用

7.环境变量的处理

项目对环境变量进行了规范处理,提高了代码的安全性和可移植性将 API 密钥、接口地址等敏感信息和环境相关配置存储在环境变量中(如使用.env 文件),通过工具(如 Vite、Create React App)将环境变量注入到前端代码中

在开发、测试和生产等不同环境中,只需修改对应的环境变量配置,无需修改代码即可实现环境的切换,避免了敏感信息泄露的风险,也方便了项目的部署和维护。

8.libs工具包

本项目将getAudioUrl单独封装到了libs工具包中,这样处理有很多好处

1. 实现代码复用,减少冗余

getAudioUrl 负责将 Base64 音频数据转换为可播放的 Blob URL,generateAudio 封装了字节 TTS API 的完整调用逻辑(包括环境变量获取、请求头构建、参数组装等)。这两个函数是项目中语音合成功能的核心依赖,当多个组件(如 AudioPlayer、ResultDisplay)需要生成音频时,无需重复编写相同逻辑,直接从 lib 包导入即可。 例如在不同页面调用语音合成时,只需一行 import { generateAudio } from '@/lib/audio',大幅减少了代码量。

2. 降低耦合度,提升可维护性

将音频处理逻辑与 UI 组件分离后,组件只需关注 “何时调用音频功能”,而无需关心 “音频如何生成”。当 TTS API 接口变更(如参数调整、域名修改)或音频处理逻辑优化(如支持更多格式)时,只需修改 lib 包中的函数,无需遍历所有调用组件。例如若字节 TTS 更换了认证方式,仅需在 generateAudio 中更新请求头,所有依赖该函数的组件会自动同步变更,降低了维护成本。

9.组件通信哲学

数据「自上而下」,行为「自下而上」

父组件作为数据的唯一源头,通过 props 将数据「向下」传递给子组件。例如父组件的 imgBase64 状态,仅通过 <PictureCard imgBase64={imgBase64} /> 传递给子组件用于预览,子组件绝不对其直接修改。

而子组件的交互行为(如上传图片、播放音频),则通过回调函数「向上」反馈给父组件,由父组件统一处理并更新状态。 比如 PictureCard 的 onUpload 回调仅传递选中的图片文件,最终由父组件的 setImgBase64 修改状态 —— 数据始终从源头流动,子组件只做「消费」而非「生产」。

五、项目总结

本项目通过整合 「月之暗面」视觉大模型和「字节 TTS」语音合成 API,构建了一个跨模态的英语学习应用,实现了从图片解析到语音输出的完整闭环。前端采用组件化设计,结合精准的 Prompt 工程和环境变量管理,确保功能解耦与安全性,同时利用 FileReader 和 Blob URL 技术优化交互体验,为用户提供流畅的 "视觉-语义-语音"学习链路

该项目验证了多模态 API 在前端落地的可行性,展示了 AI 能力与传统前端技术的深度协同。通过结构化数据转换和情感化语音合成,不仅提升了学习趣味性,也为教育类应用的智能化转型提供了可复用的技术方案,未来可扩展多语种支持和学习进度跟踪等功能。

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

多模态 AI 前端 英语学习
相关文章