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;
}
}
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%' })
}
}
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
- Add animation UI to the Stack
Stack() {
CircularWaveAnimation({
waveVm: this.waveWm
})
Button() ...
}
- 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)
}
})
- 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;
}
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
}
}
}
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)
})
}
}
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 = ''
- 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)')
- Bind the sheet.
Stack() {...}
.bindSheet($$this.openSheet, PlayerBuilder(this.selectedRecord), {
height: SheetSize.MEDIUM,
backgroundColor: Color.Black,
})
The Result
Conclusion
Congratulations everybody! Remember to record pleasant memories. See you all in new adventures. :)
~ Fortuna Favet Fortibus
Top comments (0)