はじめに
モバイルアプリやミニプログラムの開発において、音声録音機能はユーザー体験を向上させる重要な要素です。本記事では、UniAppフレームワークを使用して、録音、再生、削除、権限管理を網羅した再利用可能な録音コンポーネントを構築する方法を解説します。Vue.jsのComposition APIを採用し、モダンで保守性の高いコードベースで実装します。
コンポーネントの主な機能
- 音声のキャプチャ(開始/停止)と自動停止機能
- 録音中のリアルタイムタイマー表示
- 録音済み音声の再生と一時停止コントロール
- 最大録音時間の制限管理(デフォルト60秒)
- マイク権限の動的な確認とユーザーへのガイダンス
- 録音データの削除とリソースの解放
- 親コンポーネントとの連携用イベントとプロパティの提供
実装手順
1. テンプレート構造の定義
まず、UIの構築を行います。録音状態に応じて表示を切り替える構造にします。
<template>
<view class="audio-module-wrapper">
<!-- 録音トリガーボタン -->
<view
v-if="!mediaFilePath"
class="capture-btn"
:class="{
'is-capturing': currentStatus === 'capturing',
'is-readonly': isDisabled
}"
@tap="onTapCapture"
>
<view class="icon-area">
<text v-if="currentStatus !== 'capturing'" class="icon-mic">🎙</text>
<text v-else class="icon-stop">⏹</text>
</view>
<text class="btn-label">
{{ currentStatus === 'capturing' ? '停止' : '録音開始' }}
</text>
</view>
<!-- 録音中インジケーター -->
<view class="timer-display" v-if="currentStatus === 'capturing'">
<view class="pulse-dot"></view>
<text class="timer-text">{{ formatDuration(elapsedTime) }}</text>
</view>
<!-- 再生コントロール -->
<view class="playback-area" v-if="mediaFilePath">
<view class="playback-controls">
<view class="play-action" @tap="togglePlayback">
<text class="icon-play">{{ isPlaying ? '⏸' : '▶' }}</text>
<text class="duration-text">{{ formatDuration(totalDuration) }}</text>
</view>
<text class="discard-action" @tap="confirmDiscard">破棄</text>
</view>
</view>
</view>
</template>
2. ロジックの実装 (Composition API)
次に、スクリプト部分を実装します。Options APIではなくComposition APIを使用することで、コードの見通しを良くし、機能ごとのロジックを集約します。
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
// Props定義
const props = defineProps({
// 外部から渡される初期音声パス
initialSrc: {
type: String,
default: ''
},
// 録音可能な最大時間(秒)
maxTime: {
type: Number,
default: 60
},
// 操作の無効化フラグ
isDisabled: {
type: Boolean,
default: false
}
});
// イベント定義
const emit = defineEmits(['capture-complete', 'capture-fail', 'playback-end', 'remove-file']);
// 状態管理
const currentStatus = ref('idle'); // idle, capturing
const isPlaying = ref(false);
const elapsedTime = ref(0);
const totalDuration = ref(0);
const mediaFilePath = ref(props.initialSrc);
// インスタンス保持用変数
let recorderManager = null;
let audioContext = null;
let timerInterval = null;
onMounted(() => {
initRecorder();
if (mediaFilePath.value) {
setupAudioContext(mediaFilePath.value);
}
});
onUnmounted(() => {
cleanupResources();
});
// 録音マネージャーの初期化
const initRecorder = () => {
recorderManager = uni.getRecorderManager();
recorderManager.onStop((res) => {
const { tempFilePath, duration } = res;
if (currentStatus.value === 'capturing') {
mediaFilePath.value = tempFilePath;
totalDuration.value = Math.floor(duration / 1000);
resetTimer();
currentStatus.value = 'idle';
setupAudioContext(tempFilePath);
emit('capture-complete', {
path: tempFilePath,
length: totalDuration.value
});
}
});
recorderManager.onError((err) => {
console.error('Recording error:', err);
resetTimer();
currentStatus.value = 'idle';
uni.showToast({ title: '録音に失敗しました', icon: 'none' });
emit('capture-fail', err);
});
};
// オーディオコンテキストの設定(再生用)
const setupAudioContext = (src) => {
if (audioContext) {
audioContext.destroy();
}
audioContext = uni.createInnerAudioContext();
audioContext.src = src;
audioContext.onPlay(() => {
isPlaying.value = true;
});
audioContext.onPause(() => {
isPlaying.value = false;
});
audioContext.onEnded(() => {
isPlaying.value = false;
emit('playback-end');
});
audioContext.onError((err) => {
console.error('Playback error:', err);
isPlaying.value = false;
uni.showToast({ title: '再生エラー', icon: 'none' });
});
};
// タップ時のハンドラ(権限確認含む)
const onTapCapture = () => {
if (props.isDisabled) return;
if (currentStatus.value === 'capturing') {
stopCapture();
} else {
checkPermissionAndStart();
}
};
// 権限確認と録音開始
const checkPermissionAndStart = () => {
uni.authorize({
scope: 'scope.record',
success: () => {
startCapture();
},
fail: () => {
uni.showModal({
title: '権限が必要です',
content: 'マイクへのアクセス許可が必要です。設定画面を開きますか?',
success: (modalRes) => {
if (modalRes.confirm) {
uni.openSetting();
}
}
});
}
});
};
// 録音開始処理
const startCapture = () => {
if (audioContext) {
audioContext.stop();
isPlaying.value = false;
}
currentStatus.value = 'capturing';
elapsedTime.value = 0;
// タイマー開始
timerInterval = setInterval(() => {
elapsedTime.value++;
if (elapsedTime.value >= props.maxTime) {
stopCapture();
}
}, 1000);
recorderManager.start({
duration: props.maxTime * 1000,
format: 'mp3'
});
};
// 録音停止処理
const stopCapture = () => {
if (currentStatus.value === 'capturing') {
recorderManager.stop();
}
};
// タイマー停止・リセット
const resetTimer = () => {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
elapsedTime.value = 0;
};
// 再生/一時停止切り替え
const togglePlayback = () => {
if (!audioContext) return;
if (isPlaying.value) {
audioContext.pause();
} else {
audioContext.play();
}
};
// 削除確認
const confirmDiscard = () => {
uni.showModal({
title: '確認',
content: 'この録音を削除しますか?',
success: (res) => {
if (res.confirm) {
mediaFilePath.value = null;
totalDuration.value = 0;
if (audioContext) {
audioContext.destroy();
audioContext = null;
}
emit('remove-file');
}
}
});
};
// リソースクリーンアップ
const cleanupResources = () => {
resetTimer();
if (recorderManager) {
recorderManager.stop();
recorderManager = null;
}
if (audioContext) {
audioContext.destroy();
audioContext = null;
}
};
// 時間フォーマット (MM:SS)
const formatDuration = (seconds) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
// 外部公開メソッド
defineExpose({
getData: () => ({
path: mediaFilePath.value,
length: totalDuration.value
}),
resetState: () => {
mediaFilePath.value = null;
totalDuration.value = 0;
currentStatus.value = 'idle';
isPlaying.value = false;
cleanupResources();
}
});
</script>
3. スタイル定義
視覚的なフィードバックをわかりやすするためのスタイルを定義します。
<style lang="scss" scoped>
.audio-module-wrapper {
width: 100%;
padding: 20rpx;
box-sizing: border-box;
.capture-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 160rpx;
background-color: #f3f4f6;
border-radius: 20rpx;
transition: all 0.3s ease;
&.is-capturing {
background-color: #fee2e2;
border: 2px solid #ef4444;
}
&.is-readonly {
opacity: 0.6;
pointer-events: none;
}
.icon-area {
font-size: 48rpx;
margin-bottom: 12rpx;
}
.btn-label {
font-size: 24rpx;
color: #4b5563;
font-weight: 600;
}
}
.timer-display {
display: flex;
align-items: center;
justify-content: center;
margin-top: 24rpx;
.pulse-dot {
width: 12rpx;
height: 12rpx;
background-color: #ef4444;
border-radius: 50%;
margin-right: 16rpx;
animation: blink 1s infinite;
}
.timer-text {
font-family: monospace;
font-size: 28rpx;
color: #333;
font-weight: bold;
}
}
.playback-area {
margin-top: 20rpx;
background-color: #fff;
border: 1px solid #e5e7eb;
border-radius: 16rpx;
padding: 0 24rpx;
height: 100rpx;
.playback-controls {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
.play-action {
display: flex;
align-items: center;
gap: 16rpx;
color: #2563eb;
font-weight: 500;
.icon-play {
font-size: 32rpx;
}
.duration-text {
font-family: monospace;
font-size: 24rpx;
}
}
.discard-action {
color: #ef4444;
font-size: 24rpx;
}
}
}
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.3; }
100% { opacity: 1; }
}
</style>
コンポーネントの利用方法
作成したコンポーネントを親ページでインポートし、利用する手順です。
<template>
<view class="container">
<view class="header">ボイスメモ</view>
<AudioRecorder
ref="recorderRef"
:maxTime="30"
:initialSrc="existingAudio"
@capture-complete="onCaptureDone"
@remove-file="onFileRemoved"
/>
<button @click="submitData">送信</button>
</view>
</template>
<script setup>
import { ref } from 'vue';
import AudioRecorder from '@/components/AudioRecorder.vue';
const recorderRef = ref(null);
const existingAudio = ref('');
const onCaptureDone = (data) => {
console.log('録音完了:', data.path, data.length + '秒');
};
const onFileRemoved = () => {
console.log('録音データが削除されました');
};
const submitData = () => {
if (recorderRef.value) {
const result = recorderRef.value.getData();
console.log('送信データ:', result);
// API送信処理など
}
};
</script>
APIリファレンス
Props
| プロパティ名 | 型 | デフォルト | 説明 |
|---|---|---|---|
| initialSrc | String | '' | 初期表示する音声ファイルのパス |
| maxTime | Number | 60 | 最大録音時間(秒) |
| isDisabled | Boolean | false | コンポーネントの操作を無効化する |
Events
| イベント名 | 引数 | 説明 |
|---|---|---|
| capture-complete | { path, length } | 録音完了時に発火 |
| capture-fail | Error | 録音エラー時に発火 |
| playback-end | - | 再生完了時に発火 |
| remove-file | - | ファイル削除時に発火 |
Exposed Methods
| メソッド名 | 戻り値 | 説明 |
|---|---|---|
| getData | { path, length } | 現在の録音データと時間を取得 |
| resetState | void | コンポーネントの状態を初期化 |