본문 바로가기
Flutter

[Flutter] 실시간 Audio Wave 음성 녹음 기능 만들기

by 은행장 노씨 2024. 10. 11.

 

음성으로 대화를 하는 앱을 개발하고 있다.

음성 녹음 버튼을 누르면 음성 버튼이 활성화되며 stt(Speech to Text)가 되는 기능이다. 

실제로 stt는 패키지로 주어지기 때문에 어렵지 않게 구현했지만, 큰 벽은 바로 audio wave를 구현하는 거였다. audio_waveforms도 패키지로 있지만, 이건 이미 준비된 오디오 파일에 맞춰 그리는데 적합했다. 실시간 음성 데이터 표현을 위해서 class로 새로 구현해보는게 나을 것 같다고 판단했다.

 

Designed UI

피그마에서 디자인한 ui는 다음과 같다. 왼쪽은 활성화 전, 오른쪽은 음성 활성화 상태의 ui다. 사용자가 말하는 음성에 따라 실시간으로 파형이 변하도록 디자인했다. 

피그마 ui 디자인

 

구현 과정

(1) 안드로이드, ios 오디오 권한 부여

원하는 디바이스에 대한 권한을 부여해야 한다. 나는 안드로이드로 개발을 진행했기 때문에 

- android > app > src > main > AndroidManifest.xml

에 다음을 추가한다. 

<uses-permission android:name="android.permission.RECORD_AUDIO"/>

 

 

(2) STT 패키지 설치

STT는 flutter는 패키지를 활용하여 음성 인식을 수행했다. 일단 dependency에 해당 패키지를 추가해야 한다. 

최신 라이브러리를 설치하고 싶으면 터미널에 이렇게 입력하면 된다. 

flutter pub add speech_to_text

 

https://pub.dev/packages/speech_to_text

 

speech_to_text | Flutter package

A Flutter plugin that exposes device specific speech to text recognition capability.

pub.dev

 

이제 준비 끝이다. 

 

(3) 코드

- AudioPage

import 'package:flutter/material.dart';
import 'package:speech_to_text/speech_to_text.dart' as stt;
import 'package:remedi/ui/widgets/sound_wave_painter.dart';
import 'package:remedi/ui/widgets/recording_chat_button.dart';

class AudioPage extends StatefulWidget {
  const AudioPage({super.key});

  @override
  AudioPageState createState() => AudioPageState();
}

class AudioPageState extends State<AudioPage> {
  late stt.SpeechToText _speech;
  bool _isListening = false;
  bool _isSaved = false;
  String _sendMessages = "";
  String _recognizedText = "버튼을 눌러 음성을 인식하세요.";
  double _soundLevel = 0.0;

  @override
  void initState() {
    super.initState();
    _speech = stt.SpeechToText();
  }

  void _startListening() async {
    bool available = await _speech.initialize(
      onError: (val) => print("Error: ${val.errorMsg}"),
    );
    print("Speech available: $available");

    if (available) {
      setState(() => _isListening = true);
      _speech.listen(
        onResult: (val) => setState(() {
          _recognizedText = val.recognizedWords;
          _isSaved = false; // 새로운 녹음을 시작하면 저장 상태 초기화
        }),
        onSoundLevelChange: (level) {
          setState(() {
            _soundLevel = level;
          });
        },
        listenFor: const Duration(seconds: 30),
        pauseFor: const Duration(seconds: 30),
      );
    } else {
      print("Speech recognition not available.");
    }
  }

  void _stopListening() {
    setState(() {
      _isListening = false;
      _recognizedText = "버튼을 눌러 음성을 인식하세요."; // 초기화
      _isSaved = false;
    });
    _speech.stop();
  }

  void _saveRecording() {
    setState(() {
      _isSaved = true; // 녹음이 저장됨
      _isListening = false;
    });
    _sendMessages += _recognizedText;
    print("채팅 저장: $_recognizedText");
  }

  @override
  Widget build(BuildContext context) {
    final barCount = 12;
    final barWidth = 9.0;
    final spacing = 5.0;
    final totalWidth = barCount * barWidth + (barCount - 1) * spacing;

    return Scaffold(
      body: Stack(
        alignment: Alignment.center,
        children: [
          Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(_sendMessages, style: TextStyle(color: Colors.grey, fontSize: 18)),
                const SizedBox(height: 20),
                Text(_recognizedText, style: TextStyle(fontSize: 18)),
                const SizedBox(height: 20),
              ],
            ),
          ),
          if (_isListening)
            Positioned(
              bottom: 120.0,
              child: Container(
                width: totalWidth,
                height: 60,
                child: CustomPaint(
                  painter: SoundWavePainter(_soundLevel),
                ),
              ),
            ),
        ],
      ),
      floatingActionButton: RecordingChatButton(
        isListening: _isListening,
        onStart: _startListening,
        onStop: _stopListening,
        onSend: _saveRecording,
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
    );
  }
}

 

- RecordingChatButton

import 'package:flutter/material.dart';
import 'package:remedi/ui/themes/app_palette.dart';

class RecordingChatButton extends StatelessWidget {
  final bool isListening;
  final VoidCallback onStart;
  final VoidCallback onStop;
  final VoidCallback onSend;

  const RecordingChatButton({
    Key? key,
    required this.isListening,
    required this.onStart,
    required this.onStop,
    required this.onSend,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 36.0),
      child: Stack(
        alignment: Alignment.bottomCenter,
        children: [
          Align(
            alignment: Alignment.bottomCenter,
            child: SizedBox(
              width: 80.0,
              height: 80.0,
              child: FloatingActionButton(
                onPressed: isListening ? onSend : onStart,
                backgroundColor: isListening ? AppPalette.primaryHighlight : AppPalette.primary,
                elevation: 0,
                child: Icon(
                  isListening ? Icons.upload_rounded : Icons.mic_rounded,
                  size: 40,
                  color: isListening ? AppPalette.primary : AppPalette.white,
                ),
                shape: CircleBorder(
                  side: BorderSide(
                    color: isListening ? AppPalette.primaryDark : AppPalette.primary,
                    width: 4.0,
                  ),
                ),
              ),
            ),
          ),
          if (isListening)
            Positioned(
              bottom: 0,
              right: 90,
              child: SizedBox(
                width: 52.0,
                height: 52.0,
                child: FloatingActionButton(
                  onPressed: onStop,
                  backgroundColor: AppPalette.lightGray,
                  elevation: 0,
                  child: Icon(
                    Icons.close_rounded,
                    size: 24,
                    color: AppPalette.darkGray,
                  ),
                  shape: CircleBorder(),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

 

- SoundWavePainter: 실시간 음성 파형 구현

한가지 걸리는 점은 해당 painter는 음성의 크기로 인터렉션하지만 파형은 랜덤으로 설정된다.

파형까지 고려하지 못하는 건 아쉽다. 

import 'package:flutter/material.dart';
import 'dart:math';

class SoundWavePainter extends CustomPainter {
  final double soundLevel;
  double smoothedSoundLevel = 0.0;

  SoundWavePainter(this.soundLevel);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.grey
      ..style = PaintingStyle.fill;

    final barCount = 12;
    final barWidth = 8.0;
    final spacing = 4.0;
    final maxHeight = 20.0;
    final minHeight = 5.0;
    final cornerRadius = Radius.circular(4.0);
    final random = Random();

    smoothedSoundLevel = smoothedSoundLevel * 0.3 + soundLevel * 0.7;

    for (int i = 0; i < barCount; i++) {
      final randomFactor = 0.8 + random.nextDouble() * 0.4;
      final barHeight = max(minHeight, (smoothedSoundLevel * maxHeight * randomFactor));
      final x = i * (barWidth + spacing);
      final y = (size.height / 2) - (barHeight / 2);

      final rRect = RRect.fromRectAndRadius(
        Rect.fromLTWH(x, y, barWidth, barHeight),
        cornerRadius,
      );

      canvas.drawRRect(rRect, paint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

 

구현 결과물

긴말 하지 않겠다. 생각보다 원하는 대로 구현이 됐다. 

플러터로 실행 ui 캡처