DEV Community

HarmonyOS
HarmonyOS

Posted on

Voice Notes App with ArkTS-2

Read the original article:Voice Notes App with ArkTS-2

Introduction

Welcome back. In the first part, we have implemented the voice recorder. Now, we will learn how to play recorded files. If you haven’t see the first part check here.

But first, let’s add some animations to our recorder.

Record Animation

We will implement sound wave animations to play while recording. We will not dive into details in this article; for detailed implementation, you can check here.

ViewModel

Here we have the Canvas operations. Let’s put it under the viewmodel directory.

@Observed
export default class CircularWaveVM {
  context: CanvasRenderingContext2D = new CanvasRenderingContext2D();
  private waveData: number[] = [];
  // constants
  private canvasWidth = 0;
  private canvasHeight = 0;
  private maxNumPoints = 0;
  // You can modify these values for your case
  private minValue = 1; // used if the waveData is 0
  private maxValue = 100; // used to normalize waveData
  private radius = 0; // radius of the circle
  private numPoints = 180; // number of points on the circular wave

  start() {
    // set color
    this.context.strokeStyle = '#FFFF33';
    // set constants
    this.canvasWidth = this.context.width;
    this.canvasHeight = this.context.height;
    this.maxNumPoints = this.numPoints;
    this.radius = this.canvasWidth / 4
  }

  clear() {
    this.waveData = [];
    this.draw();
  }

  addData(num: number) {
    if (this.waveData.length === this.maxNumPoints) {
      this.waveData.shift();
    }
    this.waveData.push(num);

    this.draw();
  }

  draw() {
    // first clear
    this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);

    const centerX = this.canvasWidth / 2;
    const centerY = this.canvasHeight / 2;

    for (let i = 0; i < this.waveData.length; i++) {
      const angle = (i / this.maxNumPoints) * (2 * Math.PI);
      const radiusVariation = this.normalize(this.waveData[i]);
      const x = centerX + Math.cos(angle) * (this.radius + radiusVariation);
      const y = centerY + Math.sin(angle) * (this.radius + radiusVariation);

      // Draw wave bar
      this.context.beginPath();
      this.context.moveTo(centerX + Math.cos(angle) * this.radius, centerY + Math.sin(angle) * this.radius);
      this.context.lineTo(x, y);
      this.context.lineWidth = 2; // Width of the wave bars
      this.context.stroke();
    }
  }

  normalize(val: number): number {
    if (val === 0) {
      return this.minValue;
    }
    if (val >= this.maxValue) {
      return this.radius; // maximum distance from center
    }

    return (this.radius / this.maxValue) * val;
  }
}
Enter fullscreen mode Exit fullscreen mode

User Interface

Simple but powerful; lives in the components directory.

import CircularWaveVM from "../viewmodel/CircularWaveVM";

@Component
export struct CircularWaveAnimation {
  @ObjectLink waveVm: CircularWaveVM;

  build() {
    Stack() {
      Canvas(this.waveVm.context)
        .width('100%')
        .height('100%')
        .onReady(() => {
          this.waveVm.start()
        })
    }.size({ width: '100%', height: '100%' })
  }
}
Enter fullscreen mode Exit fullscreen mode

Recorder Page
Let’s modify the RecorderPage to see things in action.

  • Create the view model object
@State waveWm: CircularWaveVM = new CircularWaveVM()
private intervalId: number | undefined = undefined // we will come to this in a minute
Enter fullscreen mode Exit fullscreen mode
  • Add animation UI to the Stack
Stack() {
  CircularWaveAnimation({
    waveVm: this.waveWm
  })

  Button() ...
}
Enter fullscreen mode Exit fullscreen mode
  • Modify the onClick function to start and stop the animation.
.onClick(async () => {
  if (this.recorder.readyToRecord) { // start the recording
    await this.recorder.start(this.getUIContext().getHostContext()!)
    // start the animation
    this.intervalId = setInterval(async () => {
      const db = await this.recorder.getDecibel() // this will create error, do not worry
      this.waveWm.addData(db)
    }, 50)
  } else if (this.recorder.recording) { // stop and save
    // stop the animation
    clearInterval(this.intervalId)
    this.waveWm.clear()
    const path = await this.recorder.stopAndSave()
    this.records.addNew(path)
  }
})
Enter fullscreen mode Exit fullscreen mode
  • Define the getDecibel() function; modify the Recorder.
async getDecibel(): Promise<number> {
  const amp = await this.recorder?.getAudioCapturerMaxAmplitude()!
  const db = 20 * Math.log10(amp)
  return db;
}
Enter fullscreen mode Exit fullscreen mode

The Result

Player

Let's hear what we have; shall we?

Player service

Create Player.ets under the service director. We will use the AVPlayer.

import { media } from "@kit.MediaKit";
import { audio } from "@kit.AudioKit";
import { fileIo as fs } from '@kit.CoreFileKit';

@Observed
export default class Player {
  private player: media.AVPlayer | undefined = undefined
  playerState: string = "idle"

  get playing() {
    return this.playerState === 'playing'
  }

  private async init(filePath: string) {
    try {
      this.player = await media.createAVPlayer()

      this.player.on('error', (e) => {
        console.log('player on error:', e.code, e.message)
      })

      this.player.on('stateChange', async (state: string) => {
        this.playerState = state;

        switch (state) {
          case 'idle':
            console.log('state: idle');
            break;
          case 'initialized':
            console.log('state: initialized');
            this.player!.audioRendererInfo = {
              usage: audio.StreamUsage.STREAM_USAGE_MUSIC,
              rendererFlags: 0
            };
            this.player!.prepare();
            break;
          case 'prepared':
            console.log('state: prepared');
            this.player!.play();
            break;
          case 'playing':
            console.log('state: playing');
            break;
          case 'paused':
            console.log('state: paused');
            break;
          case 'completed':
            console.log('state: completed');
            break;
          case 'stopped':
            console.log('state: stopped');
            break;
          case 'released':
            console.log('state: released');
            this.player = undefined
            break;
          default:
            console.log('state: should not happen', state);
            break;
        }
      })

      let file = await fs.open(filePath)
      let fdPath = 'fd://' + file.fd.toString()
      this.player!.url = fdPath

    } catch (e) {
      console.log('init err:', e)
    }
  }

  async start(filePath: string) {
    try {
      console.log('start')
      if (!this.player) {
        console.log('init')
        await this.init(filePath)
      } else {
        console.log('start')
        await this.player.play()
      }
    } catch (e) {
      console.log('start err:', e)
    }
  }

  async pause() {
    this.player?.pause()
  }

  async stop() {
    if (this.playerState === 'prepared' || this.playerState === 'playing' || this.playerState === 'paused' ||
      this.playerState === 'completed') {
      await this.player?.stop()
      await this.player?.reset()
      await this.player?.release()
      this.player = undefined
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Create PlayerPage.ets under the components directory. This will be our UI for the Player.

import Player from "../service/Player"

@Builder
export default function PlayerBuilder(fileName: string) {
  PlayerPage({
    fileName: fileName
  })
}

@Component
struct PlayerPage {
  @Require fileName: string
  private filesDir = this.getUIContext().getHostContext()?.filesDir!
  @State player: Player = new Player()

  async aboutToDisappear() {
    await this.player.stop()
  }

  build() {
    Stack() {
      Button() {
        if (this.player.playing) {
          SymbolGlyph($r('sys.symbol.pause'))
            .fontColor([Color.White])
            .fontSize(18)
        } else {
          SymbolGlyph($r('sys.symbol.play_fill'))
            .fontColor([Color.White])
            .fontSize(18)
        }
      }
      .type(ButtonType.Circle)
      .backgroundColor(Color.Transparent)
      .size({ width: '50%', height: '50%' })
      .border({ color: Color.White, width: 5 })
      .onClick(async () => {
        if (!this.player.playing) {
          this.player.start(this.filesDir + '/' + this.fileName)
        } else {
          this.player.pause()
        }
      })
    }
    .size({ width: '100%', height: '100%' })
    .onAppear(() => {
      this.player.start(this.filesDir + '/' + this.fileName)
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

You may be wondering why there is a builder (PlayerBuilder). The answer is so simple; we will use the PlayerPage as a sheet. So we will not leave Notes page to listen.

Let’s end this. Modify Notes.ets to use the Player.

  • Define parameters for the Player.
@State openSheet: boolean = false
selectedRecord: string = ''
Enter fullscreen mode Exit fullscreen mode
  • Modify onClick function to open the sheet.
ArcListItem() {
  Button() {...}
  .onClick(async () => {
    // set selected record and open the sheet
    this.selectedRecord = path
    this.openSheet = true
  })
}.width('calc(100% - 32vp)')
Enter fullscreen mode Exit fullscreen mode
  • Bind the sheet.
Stack() {...}
.bindSheet($$this.openSheet, PlayerBuilder(this.selectedRecord), {
  height: SheetSize.MEDIUM,
  backgroundColor: Color.Black,
})
Enter fullscreen mode Exit fullscreen mode

The Result

Conclusion

Congratulations everybody! Remember to record pleasant memories. See you all in new adventures. :)

~ Fortuna Favet Fortibus

Written by Mehmet Karaaslan

Top comments (0)