语伴App音频播放功能技术解析
如果你在学习外语,想找个AI语伴练习口语,可以去鸿蒙应用市场搜索"语伴"下载体验一下。今天咱们聊聊这个App的音频播放功能。
写在前面
大家好,今天聊的语伴App,是一个AI外语学习工具。作为一个英语渣,我对这类App特别有需求。以前学英语,最大的问题就是没有语言环境,没人跟我对话。语伴App用AI解决了这个问题,可以随时随地练习口语。
音频播放是语伴App的核心功能之一。Web端播放音频用<audio>标签或者Web Audio API,鸿蒙端用的是AVPlayer。两者都能播放音频,但鸿蒙端的控制能力更强,支持更多的音频格式和播放控制。
今天这篇,我会从音频播放、TTS语音合成、录音功能这几个方面,聊聊语伴App的音频处理。
1. 音频播放:从audio标签到AVPlayer
先从基础的音频播放开始。
Web端音频播放:
// Web端音频播放
import { useRef, useState, useEffect } from 'react';
const AudioPlayer = ({ src }: { src: string }) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
const onLoadedMetadata = () => setDuration(audio.duration);
const onEnded = () => setIsPlaying(false);
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('loadedmetadata', onLoadedMetadata);
audio.addEventListener('ended', onEnded);
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
audio.removeEventListener('ended', onEnded);
};
}, []);
const togglePlay = () => {
if (isPlaying) {
audioRef.current?.pause();
} else {
audioRef.current?.play();
}
setIsPlaying(!isPlaying);
};
const seek = (time: number) => {
if (audioRef.current) {
audioRef.current.currentTime = time;
}
};
return (
<div>
<audio ref={audioRef} src={src} />
<button onClick={togglePlay}>{isPlaying ? '暂停' : '播放'}</button>
<input
type="range"
min={0}
max={duration}
value={currentTime}
onChange={(e) => seek(Number(e.target.value))}
/>
<span>{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
);
};
ArkTS音频播放:
// 鸿蒙端音频播放
import { media } from '@kit.MediaKit';
@Component
struct AudioPlayer {
@State isPlaying: boolean = false;
@State currentTime: number = 0;
@State duration: number = 0;
@State playbackSpeed: number = 1.0;
@Prop src: string = '';
private avPlayer?: media.AVPlayer;
async aboutToAppear() {
await this.initPlayer();
}
async initPlayer() {
// 创建AVPlayer
this.avPlayer = await media.createAVPlayer();
// 监听状态变化
this.avPlayer.on('stateChange', (state: string) => {
console.log('播放器状态:', state);
switch (state) {
case 'initialized':
this.avPlayer?.prepare();
break;
case 'prepared':
// 准备完成,可以播放
break;
case 'playing':
this.isPlaying = true;
break;
case 'paused':
this.isPlaying = false;
break;
case 'completed':
this.isPlaying = false;
this.currentTime = 0;
break;
case 'error':
console.error('播放错误');
break;
}
});
// 监听播放进度
this.avPlayer.on('timeUpdate', (time: number) => {
this.currentTime = time / 1000; // 转换为秒
});
// 监听duration变化
this.avPlayer.on('durationUpdate', (duration: number) => {
this.duration = duration / 1000;
});
// 设置播放源
this.avPlayer.url = this.src;
}
build() {
Column() {
// 进度条
Slider({
value: this.currentTime,
min: 0,
max: this.duration,
step: 1
})
.onChange((value: number) => {
this.seekTo(value);
})
.width('90%')
// 时间显示
Row() {
Text(formatTime(this.currentTime))
.fontSize(14)
.fontColor('#666')
Text(formatTime(this.duration))
.fontSize(14)
.fontColor('#666')
.margin({ left: 'auto' })
}
.width('90%')
.margin({ top: 8 })
// 控制按钮
Row() {
// 后退15秒
Button() {
Image($r('app.media.ic_replay_15'))
.width(32)
.height(32)
}
.backgroundColor('transparent')
.onClick(() => this.seekTo(Math.max(0, this.currentTime - 15)))
// 播放/暂停
Button() {
Image(this.isPlaying ? $r('app.media.ic_pause') : $r('app.media.ic_play'))
.width(48)
.height(48)
}
.backgroundColor('transparent')
.onClick(() => this.togglePlay())
// 前进15秒
Button() {
Image($r('app.media.ic_forward_15'))
.width(32)
.height(32)
}
.backgroundColor('transparent')
.onClick(() => this.seekTo(Math.min(this.duration, this.currentTime + 15)))
}
.margin({ top: 16 })
// 倍速选择
Row() {
Text('倍速')
.fontSize(14)
.fontColor('#666')
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], (speed: number) => {
Text(`${speed}x`)
.fontSize(14)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(this.playbackSpeed === speed ? '#2196F3' : '#f0f0f0')
.fontColor(this.playbackSpeed === speed ? '#fff' : '#333')
.borderRadius(4)
.margin({ left: 8 })
.onClick(() => this.setSpeed(speed))
})
}
.margin({ top: 16 })
}
}
togglePlay() {
if (this.isPlaying) {
this.avPlayer?.pause();
} else {
this.avPlayer?.play();
}
}
seekTo(time: number) {
if (this.avPlayer) {
this.avPlayer.seek(time * 1000);
}
}
setSpeed(speed: number) {
this.playbackSpeed = speed;
if (this.avPlayer) {
this.avPlayer.setSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X);
// 根据speed设置对应的PlaybackSpeed枚举
}
}
async aboutToDisappear() {
this.avPlayer?.release();
}
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
2. TTS语音合成:AI朗读
语伴App用TTS(Text-to-Speech)来朗读外语句子,帮助用户练习听力。
Web端TTS:
// Web端TTS
const TextToSpeech = ({ text, lang }: { text: string; lang: string }) => {
const speak = () => {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = lang; // 比如 'en-US'
utterance.rate = 0.9; // 语速
utterance.pitch = 1.0; // 音调
speechSynthesis.speak(utterance);
};
return (
<button onClick={speak}>朗读</button>
);
};
ArkTS TTS:
// 鸿蒙端TTS
import { textToSpeech } from '@kit.CoreSpeechKit';
@Component
struct TextToSpeechComponent {
@State isSpeaking: boolean = false;
@State speechRate: number = 1.0;
@Prop text: string = '';
@Prop language: string = 'en-US';
private ttsEngine?: textToSpeech.TextToSpeechEngine;
async aboutToAppear() {
await this.initTTS();
}
async initTTS() {
// 创建TTS引擎
const params: textToSpeech.CreateEngineParams = {
language: this.language,
person: 0,
online: 1
};
this.ttsEngine = await textToSpeech.createEngine(params);
// 监听播放状态
this.ttsEngine.on('startOfSpeech', () => {
this.isSpeaking = true;
});
this.ttsEngine.on('endOfSpeech', () => {
this.isSpeaking = false;
});
this.ttsEngine.on('error', (err: number) => {
console.error('TTS错误:', err);
this.isSpeaking = false;
});
}
build() {
Column() {
Text(this.text)
.fontSize(18)
.margin({ top: 20 })
Row() {
Button(this.isSpeaking ? '朗读中...' : '朗读')
.onClick(() => this.speak())
.enabled(!this.isSpeaking)
Button('停止')
.margin({ left: 12 })
.onClick(() => this.stop())
.enabled(this.isSpeaking)
}
.margin({ top: 20 })
// 语速控制
Row() {
Text('语速')
.fontSize(14)
Slider({
value: this.speechRate,
min: 0.5,
max: 2.0,
step: 0.1
})
.onChange((value: number) => {
this.speechRate = value;
})
.layoutWeight(1)
.margin({ left: 12 })
Text(`${this.speechRate.toFixed(1)}x`)
.fontSize(14)
.margin({ left: 12 })
}
.width('90%')
.margin({ top: 16 })
}
}
speak() {
if (!this.ttsEngine || !this.text) return;
const params: textToSpeech.SpeakParams = {
requestId: generateUUID(),
language: this.language,
text: this.text,
speechRate: this.speechRate
};
this.ttsEngine.speak(params);
}
stop() {
this.ttsEngine?.stop();
}
async aboutToDisappear() {
this.ttsEngine?.shutdown();
}
}
3. 录音功能:用户发音录制
语伴App需要录制用户的发音,然后进行评估。
ArkTS录音功能:
import { media } from '@kit.MediaKit';
import { audio } from '@kit.AudioKit';
@Component
struct AudioRecorder {
@State isRecording: boolean = false;
@State recordingTime: number = 0;
@State audioUri: string = '';
private avRecorder?: media.AVRecorder;
private audioCapturer?: audio.AudioCapturer;
private timer?: number;
async aboutToAppear() {
await this.initRecorder();
}
async initRecorder() {
// 创建AVRecorder
this.avRecorder = await media.createAVRecorder();
// 监听状态变化
this.avRecorder.on('stateChange', (state: string) => {
console.log('录音器状态:', state);
});
}
build() {
Column() {
// 录音时间
Text(formatTime(this.recordingTime))
.fontSize(36)
.fontWeight(FontWeight.Bold)
.fontColor(this.isRecording ? '#F44336' : '#333')
// 录音按钮
Button() {
Image(this.isRecording ? $r('app.media.ic_stop') : $r('app.media.ic_mic'))
.width(64)
.height(64)
.fillColor('#fff')
}
.width(100)
.height(100)
.borderRadius(50)
.backgroundColor(this.isRecording ? '#F44336' : '#2196F3')
.onClick(() => this.toggleRecording())
// 播放录音
if (this.audioUri) {
AudioPlayer({ src: this.audioUri })
.margin({ top: 20 })
}
}
}
async toggleRecording() {
if (this.isRecording) {
await this.stopRecording();
} else {
await this.startRecording();
}
}
async startRecording() {
if (!this.avRecorder) return;
try {
// 配置录音参数
const config: media.AVRecorderConfig = {
audioSourceType = audio.SourceType.SOURCE_TYPE_MIC,
profile = {
audioBitrate = 128000,
audioChannels = 1,
audioCodec = media.CodecMimeType.AUDIO_AAC,
audioSampleRate = 44100,
containerFormatType = media.ContainerFormatType.CFT_MPEG_4
},
url = `fd://${await this.getOutputFd()}`
};
await this.avRecorder.prepare(config);
await this.avRecorder.start();
this.isRecording = true;
this.recordingTime = 0;
// 计时器
this.timer = setInterval(() => {
this.recordingTime++;
}, 1000);
} catch (err) {
console.error('开始录音失败:', err);
}
}
async stopRecording() {
if (!this.avRecorder) return;
try {
await this.avRecorder.stop();
this.isRecording = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
}
// 获取录音文件路径
this.audioUri = await this.getRecordingUri();
} catch (err) {
console.error('停止录音失败:', err);
}
}
async getOutputFd(): Promise<number> {
// 获取文件描述符
const filePath = getContext().cacheDir + `/recording_${Date.now()}.m4a`;
const file = await fs.open(filePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
return file.fd;
}
async getRecordingUri(): Promise<string> {
// 返回录音文件URI
return getContext().cacheDir + `/recording_${Date.now()}.m4a`;
}
async aboutToDisappear() {
if (this.timer) {
clearInterval(this.timer);
}
this.avRecorder?.release();
}
}
4. 音频可视化:波形显示
录音时显示音频波形,可以让用户直观地看到自己的发音。
ArkTS音频可视化:
@Component
struct AudioVisualizer {
@State waveData: number[] = [];
@State isListening: boolean = false;
private audioCapturer?: audio.AudioCapturer;
build() {
Column() {
// 波形显示
Canvas(this.drawWave)
.width('90%')
.height(200)
.backgroundColor('#1a1a2e')
.borderRadius(12)
// 控制按钮
Button(this.isListening ? '停止' : '开始')
.onClick(() => this.toggleListening())
.margin({ top: 16 })
}
}
drawWave(ctx: CanvasRenderingContext2D) {
const width = 300;
const height = 200;
const centerY = height / 2;
// 清空画布
ctx.clearRect(0, 0, width, height);
// 绘制波形
ctx.beginPath();
ctx.moveTo(0, centerY);
this.waveData.forEach((value, index) => {
const x = (index / this.waveData.length) * width;
const y = centerY + (value * centerY * 0.8);
ctx.lineTo(x, y);
});
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
ctx.stroke();
// 绘制中心线
ctx.beginPath();
ctx.moveTo(0, centerY);
ctx.lineTo(width, centerY);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
ctx.lineWidth = 1;
ctx.stroke();
}
async toggleListening() {
if (this.isListening) {
await this.stopListening();
} else {
await this.startListening();
}
}
async startListening() {
// 创建音频捕获器
const capturerOptions: audio.AudioCapturerOptions = {
streamInfo: {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100,
channels: audio.AudioChannel.CHANNEL_1,
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
},
capturerInfo: {
source: audio.SourceType.SOURCE_TYPE_MIC,
capturerFlags: 0
}
};
this.audioCapturer = await audio.createAudioCapturer(capturerOptions);
// 开始捕获
await this.audioCapturer.start();
this.isListening = true;
// 读取音频数据并更新波形
this.readAudioData();
}
async readAudioData() {
if (!this.audioCapturer || !this.isListening) return;
const bufferSize = 1024;
const buffer = new ArrayBuffer(bufferSize);
while (this.isListening) {
const bytesRead = await this.audioCapturer.read(buffer, false);
if (bytesRead > 0) {
// 处理音频数据
const int16Array = new Int16Array(buffer, 0, bytesRead / 2);
const waveformData = this.processAudioData(int16Array);
// 更新波形数据
this.waveData = waveformData;
}
}
}
processAudioData(data: Int16Array): number[] {
const samples = 64;
const result: number[] = [];
const step = Math.floor(data.length / samples);
for (let i = 0; i < samples; i++) {
const start = i * step;
const end = start + step;
let sum = 0;
for (let j = start; j < end; j++) {
sum += Math.abs(data[j]);
}
// 归一化到 -1 到 1
result.push((sum / step / 32768) * 2 - 1);
}
return result;
}
async stopListening() {
this.isListening = false;
await this.audioCapturer?.stop();
await this.audioCapturer?.release();
this.audioCapturer = undefined;
}
async aboutToDisappear() {
await this.stopListening();
}
}
5. 音频缓存:离线播放
音频文件需要缓存,这样离线时也能播放。
ArkTS音频缓存:
class AudioCacheManager {
private context: Context;
private maxCacheSize: number = 100 * 1024 * 1024; // 100MB
constructor(context: Context) {
this.context = context;
}
// 下载并缓存音频
async cacheAudio(url: string, id: string): Promise<string> {
const cachePath = this.getCachePath(id);
// 检查是否已缓存
if (await this.isCached(id)) {
return cachePath;
}
// 下载音频
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
// 保存到缓存
const file = await fs.open(cachePath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
await fs.write(file.fd, arrayBuffer);
await fs.close(file);
// 检查缓存大小
await this.cleanupCache();
return cachePath;
}
// 获取缓存路径
getCachePath(id: string): string {
return `${this.context.cacheDir}/audio/${id}.mp3`;
}
// 检查是否已缓存
async isCached(id: string): Promise<boolean> {
try {
const path = this.getCachePath(id);
await fs.access(path);
return true;
} catch {
return false;
}
}
// 清理缓存
async cleanupCache() {
const cacheDir = `${this.context.cacheDir}/audio`;
try {
const files = await fs.listFile(cacheDir);
let totalSize = 0;
// 获取所有文件信息
const fileInfos = await Promise.all(
files.map(async (file) => {
const path = `${cacheDir}/${file}`;
const stat = await fs.stat(path);
return { path, size: stat.size, mtime: stat.mtime };
})
);
// 按修改时间排序
fileInfos.sort((a, b) => a.mtime - b.mtime);
// 删除旧文件直到缓存大小符合要求
for (const fileInfo of fileInfos) {
totalSize += fileInfo.size;
if (totalSize > this.maxCacheSize) {
await fs.unlink(fileInfo.path);
}
}
} catch (err) {
console.error('清理缓存失败:', err);
}
}
// 获取缓存大小
async getCacheSize(): Promise<number> {
const cacheDir = `${this.context.cacheDir}/audio`;
try {
const files = await fs.listFile(cacheDir);
let totalSize = 0;
for (const file of files) {
const stat = await fs.stat(`${cacheDir}/${file}`);
totalSize += stat.size;
}
return totalSize;
} catch {
return 0;
}
}
// 清空缓存
async clearCache() {
const cacheDir = `${this.context.cacheDir}/audio`;
try {
const files = await fs.listFile(cacheDir);
for (const file of files) {
await fs.unlink(`${cacheDir}/${file}`);
}
} catch (err) {
console.error('清空缓存失败:', err);
}
}
}
总结
语伴App的音频播放功能,从基础播放、TTS合成、录音功能到音频可视化和缓存管理,每一部分都有它的技术要点。鸿蒙端的AVPlayer和AudioCapturer提供了强大的音频处理能力,用起来比Web端的Audio API更灵活。
如果你在做音频相关的App,建议先把AVPlayer的API熟悉一下,这是鸿蒙音频处理的核心。TTS和录音功能也很实用,可以做出很多有趣的应用。
下一篇文章,我会聊聊语伴App的闪卡管理功能,包括闪卡设计、记忆算法这些。