UniAppを活用したクロスプラットフォーム対応録音コンポーネントの設計と実装

はじめに

モバイルアプリやミニプログラムの開発において、音声録音機能はユーザー体験を向上させる重要な要素です。本記事では、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 コンポーネントの状態を初期化

7月6日 18:20 投稿