鸿蒙开发案例:分贝仪_bundle

【1】引言(完整代码在最后面)

分贝仪是一个简单的应用,用于测量周围环境的噪音水平。通过麦克风采集音频数据,计算当前的分贝值,并在界面上实时显示。该应用不仅展示了鸿蒙系统的基础功能,还涉及到了权限管理、音频处理和UI设计等多个方面。

【2】环境准备

电脑系统:windows 10

开发工具:DevEco Studio NEXT Beta1 Build Version: 5.0.3.806

工程版本:API 12

真机:mate60 pro

语言:ArkTS、ArkUI

权限:ohos.permission.MICROPHONE(麦克风权限)

系统库:

• @kit.AudioKit:用于音频处理的库。

• @kit.AbilityKit:用于权限管理和应用能力的库。

• @kit.BasicServicesKit:提供基本的服务支持,如错误处理等。

【3】功能模块

3.1 权限管理

在使用麦克风之前,需要请求用户的权限。如果用户拒绝,会显示一个对话框引导用户手动开启权限。

// 请求用户权限
requestPermissionsFromUser() {
  const context = getContext(this) as common.UIAbilityContext;
  const atManager = abilityAccessCtrl.createAtManager();
  atManager.requestPermissionsFromUser(context, this.requiredPermissions, (err, data) => {
    const grantStatus: Array<number> = data.authResults;
    if (grantStatus.toString() == "-1") {
      this.showAlertDialog();
    } else if (grantStatus.toString() == "0") {
      this.initialize();
    }
  });
}

3.2 分贝计算

通过读取麦克风采集的音频数据,计算当前环境的分贝值。计算过程中会对音频样本进行归一化处理,并计算其均方根(RMS)值,最终转换成分贝值。

// 分贝计算
calculateDecibel(pcm: ArrayBuffer): number {
  let sum = 0;
  const pcmView = new DataView(pcm);
  const numSamples = pcm.byteLength / 2;

  for (let i = 0; i < pcm.byteLength; i += 2) {
    const sample = pcmView.getInt16(i, true) / 32767.0;
    sum += sample * sample;
  }

  const meanSquare = sum / numSamples;
  const rmsAmplitude = Math.sqrt(meanSquare);
  const referencePressure = 20e-6;
  const decibels = 20 * Math.log10(rmsAmplitude / referencePressure);

  if (isNaN(decibels)) {
    return -100;
  }

  const minDb = 20;
  const maxDb = 100;
  const mappedValue = ((decibels - minDb) / (maxDb - minDb)) * 100;
  return Math.max(0, Math.min(100, mappedValue));
}

3.3 UI设计

界面上包含一个仪表盘显示当前分贝值,以及一段文字描述当前的噪音水平。分贝值被映射到0到100的范围内,以适应仪表盘的显示需求。界面上还有两个按钮,分别用于开始和停止分贝测量。

// 构建UI
build() {
  Column() {
    Text("分贝仪")
      .width('100%')
      .height(44)
      .backgroundColor("#fe9900")
      .textAlign(TextAlign.Center)
      .fontColor(Color.White);

    Row() {
      Gauge({ value: this.currentDecibel, min: 1, max: 100 }) {
        Column() {
          Text(`${this.displayedDecibel}分贝`)
            .fontSize(25)
            .fontWeight(FontWeight.Medium)
            .fontColor("#323232")
            .width('40%')
            .height('30%')
            .textAlign(TextAlign.Center)
            .margin({ top: '22.2%' })
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .maxLines(1);

          Text(`${this.displayType}`)
            .fontSize(16)
            .fontColor("#848484")
            .fontWeight(FontWeight.Regular)
            .width('47.4%')
            .height('15%')
            .textAlign(TextAlign.Center)
            .backgroundColor("#e4e4e4")
            .borderRadius(5);
        }.width('100%');
      }
      .startAngle(225)
      .endAngle(135)
      .colors(this.gaugeColors)
      .height(250)
      .strokeWidth(18)
      .description(null)
      .trackShadow({ radius: 7, offsetX: 7, offsetY: 7 })
      .padding({ top: 30 });
    }.width('100%').justifyContent(FlexAlign.Center);

    Column() {
      ForEach(this.typeArray, (item: ValueBean, index: number) => {
        Row() {
          Text(item.description)
            .textAlign(TextAlign.Start)
            .fontColor("#3d3d3d");
        }.width(250)
          .padding({ bottom: 10, top: 10 })
          .borderWidth({ bottom: 1 })
          .borderColor("#737977");
      });
    }.width('100%');

    Row() {
      Button('开始检测').clickEffect({ level: ClickEffectLevel.LIGHT }).onClick(() => {
        if (this.audioRecorder) {
          this.startRecording();
        } else {
          this.requestPermissionsFromUser();
        }
      });

      Button('停止检测').clickEffect({ level: ClickEffectLevel.LIGHT }).onClick(() => {
        if (this.audioRecorder) {
          this.stopRecording();
        }
      });
    }.width('100%')
      .justifyContent(FlexAlign.SpaceEvenly)
      .padding({
        left: 20,
        right: 20,
        top: 40,
        bottom: 40
      });
  }.height('100%').width('100%');
}

【4】关键代码解析

4.1 权限检查与请求

在应用启动时,首先检查是否已经获得了麦克风权限。如果没有获得权限,则请求用户授权。

// 检查权限
checkPermissions() {
  const atManager = abilityAccessCtrl.createAtManager();
  const bundleInfo = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
  const tokenId = bundleInfo.appInfo.accessTokenId;

  const authResults = this.requiredPermissions.map((permission) => atManager.checkAccessTokenSync(tokenId, permission));
  return authResults.every(v => v === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED);
}

// 请求用户权限
requestPermissionsFromUser() {
  const context = getContext(this) as common.UIAbilityContext;
  const atManager = abilityAccessCtrl.createAtManager();
  atManager.requestPermissionsFromUser(context, this.requiredPermissions, (err, data) => {
    const grantStatus: Array<number> = data.authResults;
    if (grantStatus.toString() == "-1") {
      this.showAlertDialog();
    } else if (grantStatus.toString() == "0") {
      this.initialize();
    }
  });
}

4.2 音频记录器初始化

在获得权限后,初始化音频记录器,设置采样率、通道数、采样格式等参数,并开始监听音频数据。

// 初始化音频记录器
initialize() {
  const streamInfo: audio.AudioStreamInfo = {
    samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100,
    channels: audio.AudioChannel.CHANNEL_1,
    sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
    encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
  };

  const recorderInfo: audio.AudioCapturerInfo = {
    source: audio.SourceType.SOURCE_TYPE_MIC,
    capturerFlags: 0
  };

  const recorderOptions: audio.AudioCapturerOptions = {
    streamInfo: streamInfo,
    capturerInfo: recorderInfo
  };

  audio.createAudioCapturer(recorderOptions, (err, recorder) => {
    if (err) {
      console.error(`创建音频记录器失败, 错误码: ${err.code}, 错误信息: ${err.message}`);
      return;
    }
    console.info(`${this.TAG}: 音频记录器创建成功`);
    this.audioRecorder = recorder;

    if (this.audioRecorder !== undefined) {
      this.audioRecorder.on('readData', (buffer: ArrayBuffer) => {
        this.currentDecibel = this.calculateDecibel(buffer);
        this.updateDisplay();
      });
    }
  });
}

4.3 更新显示

每秒钟更新一次显示的分贝值,并根据当前分贝值确定其所属的噪音级别。

// 更新显示
updateDisplay() {
  if (Date.now() - this.lastUpdateTimestamp > 1000) {
    this.lastUpdateTimestamp = Date.now();
    this.displayedDecibel = Math.floor(this.currentDecibel);

    for (const item of this.typeArray) {
      if (this.currentDecibel >= item.minDb && this.currentDecibel < item.maxDb) {
        this.displayType = item.label;
        break;
      }
    }
  }
}

【5】完整代码

5.1 配置麦克风权限

路径:src/main/module.json5

{

  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.MICROPHONE",
        "reason": "$string:microphone_reason",
        "usedScene": {
          "abilities": [
            "EntryAbility"
          ],
          "when":"inuse"
        }
      }
    ],

5.2 配置权限弹窗时的描述文字

路径:src/main/resources/base/element/string.json

{
  "string": [
    {
      "name": "module_desc",
      "value": "module description"
    },
    {
      "name": "EntryAbility_desc",
      "value": "description"
    },
    {
      "name": "EntryAbility_label",
      "value": "label"
    },
    {
      "name": "microphone_reason",
      "value": "需要麦克风权限说明"
    }
  ]
}

5.3 完整代码

路径:src/main/ets/pages/Index.ets

import { audio } from '@kit.AudioKit'; // 导入音频相关的库
import { abilityAccessCtrl, bundleManager, common, Permissions } from '@kit.AbilityKit'; // 导入权限管理相关的库
import { BusinessError } from '@kit.BasicServicesKit'; // 导入业务错误处理

// 定义一个类,用于存储分贝范围及其描述
class ValueBean {
  label: string; // 标签
  description: string; // 描述
  minDb: number; // 最小分贝值
  maxDb: number; // 最大分贝值
  colorStart: string; // 起始颜色
  colorEnd: string; // 结束颜色

  // 构造函数,初始化属性
  constructor(label: string, description: string, minDb: number, maxDb: number, colorStart: string, colorEnd: string) {
    this.label = label;
    this.description = description;
    this.minDb = minDb;
    this.maxDb = maxDb;
    this.colorStart = colorStart;
    this.colorEnd = colorEnd;
  }
}

// 定义分贝仪组件
@Entry
@Component
struct DecibelMeter {
  TAG: string = 'DecibelMeter'; // 日志标签
  audioRecorder: audio.AudioCapturer | undefined = undefined; // 音频记录器
  requiredPermissions: Array<Permissions> = ['ohos.permission.MICROPHONE']; // 需要的权限
  @State currentDecibel: number = 0; // 当前分贝值
  @State displayedDecibel: number = 0; // 显示的分贝值
  lastUpdateTimestamp: number = 0; // 上次更新时间戳
  @State displayType: string = ''; // 当前显示类型
  // 定义分贝范围及其描述
  typeArray: ValueBean[] = [
    new ValueBean("寂静", "0~20dB : 寂静,几乎感觉不到", 0, 20, "#02b003", "#016502"),
    new ValueBean("安静", '20~40dB :安静,轻声交谈', 20, 40, "#7ed709", "#4f8800"),
    new ValueBean("正常", '40~60dB :正常,普通室内谈话', 40, 60, "#ffef01", "#ad9e04"),
    new ValueBean("吵闹", '60~80dB :吵闹,大声说话', 60, 80, "#f88200", "#965001"),
    new ValueBean("很吵", '80~100dB: 很吵,可使听力受损', 80, 100, "#f80000", "#9d0001"),
  ];
  gaugeColors: [LinearGradient, number][] = [] // 存储仪表颜色的数组

  // 组件即将出现时调用
  aboutToAppear(): void {
    // 初始化仪表颜色
    for (let i = 0; i < this.typeArray.length; i++) {
      this.gaugeColors.push([new LinearGradient([{ color: this.typeArray[i].colorStart, offset: 0 },
        { color: this.typeArray[i].colorEnd, offset: 1 }]), 1])
    }
  }

  // 请求用户权限
  requestPermissionsFromUser() {
    const context = getContext(this) as common.UIAbilityContext; // 获取上下文
    const atManager = abilityAccessCtrl.createAtManager(); // 创建权限管理器
    // 请求权限
    atManager.requestPermissionsFromUser(context, this.requiredPermissions, (err, data) => {
      const grantStatus: Array<number> = data.authResults; // 获取授权结果
      if (grantStatus.toString() == "-1") { // 用户拒绝权限
        this.showAlertDialog(); // 显示提示对话框
      } else if (grantStatus.toString() == "0") { // 用户同意权限
        this.initialize(); // 初始化音频记录器
      }
    });
  }

  // 显示对话框提示用户开启权限
  showAlertDialog() {
    this.getUIContext().showAlertDialog({
      autoCancel: true, // 自动取消
      title: '权限申请', // 对话框标题
      message: '如需使用此功能,请前往设置页面开启麦克风权限。', // 对话框消息
      cancel: () => {
      },
      confirm: {
        defaultFocus: true, // 默认聚焦确认按钮
        value: '好的', // 确认按钮文本
        action: () => {
          this.openPermissionSettingsPage(); // 打开权限设置页面
        }
      },
      onWillDismiss: () => {
      },
      alignment: DialogAlignment.Center, // 对话框对齐方式
    });
  }

  // 打开权限设置页面
  openPermissionSettingsPage() {
    const context = getContext() as common.UIAbilityContext; // 获取上下文
    const bundleInfo =
      bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); // 获取包信息
    context.startAbility({
      bundleName: 'com.huawei.hmos.settings', // 设置页面的包名
      abilityName: 'com.huawei.hmos.settings.MainAbility', // 设置页面的能力名
      uri: 'application_info_entry', // 打开设置->应用和元服务
      parameters: {
        pushParams: bundleInfo.name // 按照包名打开对应设置页
      }
    });
  }

  // 分贝计算
  calculateDecibel(pcm: ArrayBuffer): number {
    let sum = 0; // 初始化平方和
    const pcmView = new DataView(pcm); // 创建数据视图
    const numSamples = pcm.byteLength / 2; // 计算样本数量

    // 归一化样本值并计算平方和
    for (let i = 0; i < pcm.byteLength; i += 2) {
      const sample = pcmView.getInt16(i, true) / 32767.0; // 归一化样本值
      sum += sample * sample; // 计算平方和
    }

    // 计算平均平方值
    const meanSquare = sum / numSamples; // 计算均方

    // 计算RMS(均方根)振幅
    const rmsAmplitude = Math.sqrt(meanSquare); // 计算RMS值

    // 使用标准参考压力值
    const referencePressure = 20e-6; // 20 μPa

    // 计算分贝值
    const decibels = 20 * Math.log10(rmsAmplitude / referencePressure); // 计算分贝

    // 处理NaN值
    if (isNaN(decibels)) {
      return -100; // 返回一个极小值表示静音
    }

    // 调整动态范围
    const minDb = 20; // 调整最小分贝值
    const maxDb = 100; // 调整最大分贝值

    // 将分贝值映射到0到100之间的范围
    const mappedValue = ((decibels - minDb) / (maxDb - minDb)) * 100; // 映射分贝值

    // 确保值在0到100之间
    return Math.max(0, Math.min(100, mappedValue)); // 返回映射后的值
  }

  // 初始化音频记录器
  initialize() {
    const streamInfo: audio.AudioStreamInfo = {
      samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100, // 采样率
      channels: audio.AudioChannel.CHANNEL_1, // 单声道
      sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, // 采样格式
      encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW // 编码类型
    };
    const recorderInfo: audio.AudioCapturerInfo = {
      source: audio.SourceType.SOURCE_TYPE_MIC, // 音频源为麦克风
      capturerFlags: 0 // 捕获标志
    };
    const recorderOptions: audio.AudioCapturerOptions = {
      streamInfo: streamInfo, // 音频流信息
      capturerInfo: recorderInfo // 记录器信息
    };
    // 创建音频记录器
    audio.createAudioCapturer(recorderOptions, (err, recorder) => {
      if (err) {
        console.error(`创建音频记录器失败, 错误码: ${err.code}, 错误信息: ${err.message}`); // 错误处理
        return;
      }
      console.info(`${this.TAG}: 音频记录器创建成功`); // 成功日志
      this.audioRecorder = recorder; // 保存记录器实例
      if (this.audioRecorder !== undefined) {
        // 监听音频数据
        this.audioRecorder.on('readData', (buffer: ArrayBuffer) => {
          this.currentDecibel = this.calculateDecibel(buffer); // 计算当前分贝值
          this.updateDisplay(); // 更新显示
        });
      }
      this.startRecording(); // 开始录音
    });
  }

  // 开始录音
  startRecording() {
    if (this.audioRecorder !== undefined) { // 检查音频记录器是否已定义
      this.audioRecorder.start((err: BusinessError) => { // 调用开始录音方法
        if (err) {
          console.error('开始录音失败'); // 记录错误信息
        } else {
          console.info('开始录音成功'); // 记录成功信息
        }
      });
    }
  }

  // 停止录音
  stopRecording() {
    if (this.audioRecorder !== undefined) { // 检查音频记录器是否已定义
      this.audioRecorder.stop((err: BusinessError) => { // 调用停止录音方法
        if (err) {
          console.error('停止录音失败'); // 记录错误信息
        } else {
          console.info('停止录音成功'); // 记录成功信息
        }
      });
    }
  }

  // 更新显示
  updateDisplay() {
    if (Date.now() - this.lastUpdateTimestamp > 1000) { // 每隔1秒更新一次显示
      this.lastUpdateTimestamp = Date.now(); // 更新最后更新时间戳
      this.displayedDecibel = Math.floor(this.currentDecibel); // 将当前分贝值取整并赋值给显示的分贝值
      // 遍历分贝类型数组,确定当前分贝值对应的类型
      for (const item of this.typeArray) {
        if (this.currentDecibel >= item.minDb && this.currentDecibel < item.maxDb) { // 检查当前分贝值是否在某个范围内
          this.displayType = item.label; // 设置当前显示类型
          break; // 找到对应类型后退出循环
        }
      }
    }
  }

  // 检查权限
  checkPermissions() {
    const atManager = abilityAccessCtrl.createAtManager(); // 创建权限管理器
    const bundleInfo =
      bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); // 获取包信息
    const tokenId = bundleInfo.appInfo.accessTokenId; // 获取应用的唯一标识
    // 检查每个权限的授权状态
    const authResults =
      this.requiredPermissions.map((permission) => atManager.checkAccessTokenSync(tokenId, permission));
    return authResults.every(v => v === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED); // 返回是否所有权限都被授予
  }

  // 构建UI
  build() {
    Column() {
      Text("分贝仪")// 显示标题
        .width('100%')// 设置宽度为100%
        .height(44)// 设置高度为44
        .backgroundColor("#fe9900")// 设置背景颜色
        .textAlign(TextAlign.Center)// 设置文本对齐方式
        .fontColor(Color.White); // 设置字体颜色

      Row() {
        Gauge({ value: this.currentDecibel, min: 1, max: 100 }) { // 创建仪表,显示当前分贝值
          Column() {
            Text(`${this.displayedDecibel}分贝`)// 显示当前分贝值
              .fontSize(25)// 设置字体大小
              .fontWeight(FontWeight.Medium)// 设置字体粗细
              .fontColor("#323232")// 设置字体颜色
              .width('40%')// 设置宽度为40%
              .height('30%')// 设置高度为30%
              .textAlign(TextAlign.Center)// 设置文本对齐方式
              .margin({ top: '22.2%' })// 设置上边距
              .textOverflow({ overflow: TextOverflow.Ellipsis })// 设置文本溢出处理
              .maxLines(1); // 设置最大行数为1

            Text(`${this.displayType}`)// 显示当前类型
              .fontSize(16)// 设置字体大小
              .fontColor("#848484")// 设置字体颜色
              .fontWeight(FontWeight.Regular)// 设置字体粗细
              .width('47.4%')// 设置宽度为47.4%
              .height('15%')// 设置高度为15%
              .textAlign(TextAlign.Center)// 设置文本对齐方式
              .backgroundColor("#e4e4e4")// 设置背景颜色
              .borderRadius(5); // 设置圆角
          }.width('100%'); // 设置列宽度为100%
        }
        .startAngle(225) // 设置仪表起始角度
        .endAngle(135) // 设置仪表结束角度
        .colors(this.gaugeColors) // 设置仪表颜色
        .height(250) // 设置仪表高度
        .strokeWidth(18) // 设置仪表边框宽度
        .description(null) // 设置描述为null
        .trackShadow({ radius: 7, offsetX: 7, offsetY: 7 }) // 设置阴影效果
        .padding({ top: 30 }); // 设置内边距
      }.width('100%').justifyContent(FlexAlign.Center); // 设置行宽度为100%并居中对齐

      Column() {
        ForEach(this.typeArray, (item: ValueBean, index: number) => { // 遍历分贝类型数组
          Row() {
            Text(item.description)// 显示每个类型的描述
              .textAlign(TextAlign.Start)// 设置文本对齐方式
              .fontColor("#3d3d3d"); // 设置字体颜色
          }.width(250) // 设置行宽度为250
          .padding({ bottom: 10, top: 10 }) // 设置上下内边距
          .borderWidth({ bottom: 1 }) // 设置下边框宽度
          .borderColor("#737977"); // 设置下边框颜色
        });
      }.width('100%'); // 设置列宽度为100%

      Row() {
        Button('开始检测').clickEffect({ level: ClickEffectLevel.LIGHT }).onClick(() => { // 创建开始检测按钮
          if (this.audioRecorder) { // 检查音频记录器是否已定义
            this.startRecording(); // 开始录音
          } else {
            this.requestPermissionsFromUser(); // 请求用户权限
          }
        });

        Button('停止检测').clickEffect({ level: ClickEffectLevel.LIGHT }).onClick(() => { // 创建停止检测按钮
          if (this.audioRecorder) { // 检查音频记录器是否已定义
            this.stopRecording(); // 停止录音
          }
        });
      }.width('100%') // 设置行宽度为100%
      .justifyContent(FlexAlign.SpaceEvenly) // 设置内容均匀分布
      .padding({
        // 设置内边距
        left: 20,
        right: 20,
        top: 40,
        bottom: 40
      });
    }.height('100%').width('100%'); // 设置列高度和宽度为100%
  }

  // 页面显示时的处理
  onPageShow(): void {
    const hasPermission = this.checkPermissions(); // 检查权限
    console.info(`麦克风权限状态: ${hasPermission ? '已开启' : '未开启'}`); // 打印权限状态
    if (hasPermission) { // 如果权限已开启
      if (this.audioRecorder) { // 检查音频记录器是否已定义
        this.startRecording(); // 开始录音
      } else {
        this.requestPermissionsFromUser(); // 请求用户权限
      }
    }
  }
}