鸿蒙APP开发-语伴App音频播放功能技术解析

0 阅读4分钟

语伴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的闪卡管理功能,包括闪卡设计、记忆算法这些。