鴻蒙開發案例:分貝儀

zhongcx發表於2024-11-03

【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(); // 請求使用者許可權
      }
    }
  }
}

  

相關文章