Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# .coderabbit.yaml
language: "ko-KR"
early_access: false
reviews:
profile: "chill"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

YAMLlint 오류: 후행 공백 제거 필요

Line 5 끝에 공백이 있어 linter가 에러를 보고합니다. 아래처럼 공백을 제거하세요.

-  profile: "chill" 
+  profile: "chill"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
profile: "chill"
profile: "chill"
🧰 Tools
🪛 YAMLlint (1.37.1)

[error] 5-5: trailing spaces

(trailing-spaces)

🤖 Prompt for AI Agents
In .coderabbit.yaml around line 5, the value line 'profile: "chill" ' contains a
trailing space that triggers YAML lint errors; remove the trailing whitespace at
the end of line 5 so it becomes 'profile: "chill"' and save the file to clear
the linter error.

request_changes_workflow: false
high_level_summary: true
poem: true
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

YAMLlint 오류: 후행 공백 제거 필요

Line 8 끝에 공백이 있어 linter가 에러를 보고합니다. 아래처럼 공백을 제거하세요.

-  poem: true 
+  poem: true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
poem: true
poem: true
🧰 Tools
🪛 YAMLlint (1.37.1)

[error] 8-8: trailing spaces

(trailing-spaces)

🤖 Prompt for AI Agents
.coderabbit.yaml around line 8: there's a trailing space after "poem: true"
causing YAML linter error; remove the trailing whitespace at the end of line 8
so the line reads without any spaces after "true".

review_status: true
collapse_walkthrough: false
auto_review:
enabled: true
drafts: false
chat:
auto_reply: true
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

YAMLlint 오류: 파일 끝 개행 추가 필요

파일 끝에 개행(newline)이 없어 linter가 에러를 보고합니다. 개행을 하나 추가해 주세요.

-  auto_reply: true
+  auto_reply: true
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
auto_reply: true
auto_reply: true
🧰 Tools
🪛 YAMLlint (1.37.1)

[error] 15-15: no new line character at the end of file

(new-line-at-end-of-file)

🤖 Prompt for AI Agents
.coderabbit.yaml around line 15: the file is missing a trailing newline at EOF
which triggers YAMLlint; add a single newline character at the end of the file
(ensure the file ends with a blank line) and save to satisfy the linter.

223 changes: 187 additions & 36 deletions Assets/Core/Audio/AudioRecorder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,64 @@
using System;
using UnityEngine;
using System.Collections.Generic;
using ProjectVG.Infrastructure.Audio;

namespace ProjectVG.Core.Audio
{
/// <summary>
/// 정확한 시간 기반 음성 녹음 시스템
/// 녹음 시작/중지 시간을 기반으로 정확한 길이의 오디오를 생성합니다.
/// </summary>
public class AudioRecorder : Singleton<AudioRecorder>
{
[Header("Recording Settings")]
[SerializeField] private int _sampleRate = 44100;
[SerializeField] private int _channels = 1;
[SerializeField] private int _maxRecordingLength = 30;
[SerializeField] private int _maxRecordingLength = 30; // 최대 녹음 시간 (초)

[Header("Audio Processing")]
[SerializeField] private bool _enableNoiseReduction = false; // 노이즈 제거 비활성화
[SerializeField] private float _silenceThreshold = 0.001f; // 무음 임계값 낮춤

private AudioClip? _recordingClip;
private bool _isRecording = false;
private float _recordingStartTime;
private List<float> _audioBuffer;

public bool IsRecording => _isRecording;
public float RecordingDuration => _isRecording ? Time.time - _recordingStartTime : 0f;
public bool IsRecordingAvailable => Microphone.devices.Length > 0;
private float _recordingEndTime;
private string? _currentDevice = null;

// 이벤트
public event Action? OnRecordingStarted;
public event Action? OnRecordingStopped;
public event Action<AudioClip>? OnRecordingCompleted;
public event Action<string>? OnError;
public event Action<float>? OnRecordingProgress; // 녹음 진행률 (0-1)

// 프로퍼티
public bool IsRecording => _isRecording;
public float RecordingDuration => _isRecording ? Time.time - _recordingStartTime : 0f;
public bool IsRecordingAvailable => Microphone.devices.Length > 0;
public float RecordingProgress => _isRecording ? Mathf.Clamp01(RecordingDuration / _maxRecordingLength) : 0f;

#region Unity Lifecycle

protected override void Awake()
{
base.Awake();
_audioBuffer = new List<float>();
InitializeMicrophone();
}

private void Update()
{
if (_isRecording && RecordingDuration >= _maxRecordingLength)
if (_isRecording)
{
StopRecording();
// 녹음 진행률 이벤트 발생
OnRecordingProgress?.Invoke(RecordingProgress);

// 최대 녹음 시간 체크
if (RecordingDuration >= _maxRecordingLength)
{
StopRecording();
}
}
}
Comment on lines +53 to 64
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Update() method performs unnecessary work every frame

The Update() method fires the progress event and checks duration every frame when recording, which is inefficient for UI updates that don't need 60+ FPS refresh rates.

Throttle the update frequency:

+private float _lastProgressUpdate = 0f;
+private const float PROGRESS_UPDATE_INTERVAL = 0.1f; // Update 10 times per second

 private void Update()
 {
     if (_isRecording)
     {
-        // 녹음 진행률 이벤트 발생
-        OnRecordingProgress?.Invoke(RecordingProgress);
+        // Throttle progress updates
+        if (Time.time - _lastProgressUpdate >= PROGRESS_UPDATE_INTERVAL)
+        {
+            OnRecordingProgress?.Invoke(RecordingProgress);
+            _lastProgressUpdate = Time.time;
+        }
         
         // 최대 녹음 시간 체크
         if (RecordingDuration >= _maxRecordingLength)
         {
             StopRecording();
         }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (_isRecording)
{
StopRecording();
// 녹음 진행률 이벤트 발생
OnRecordingProgress?.Invoke(RecordingProgress);
// 최대 녹음 시간 체크
if (RecordingDuration >= _maxRecordingLength)
{
StopRecording();
}
}
}
// Add these at class scope
private float _lastProgressUpdate = 0f;
private const float PROGRESS_UPDATE_INTERVAL = 0.1f; // Update 10 times per second
private void Update()
{
if (_isRecording)
{
// Throttle progress updates
if (Time.time - _lastProgressUpdate >= PROGRESS_UPDATE_INTERVAL)
{
OnRecordingProgress?.Invoke(RecordingProgress);
_lastProgressUpdate = Time.time;
}
// 최대 녹음 시간 체크
if (RecordingDuration >= _maxRecordingLength)
{
StopRecording();
}
}
}
🤖 Prompt for AI Agents
In Assets/Core/Audio/AudioRecorder.cs around lines 53 to 64, the Update() method
currently invokes OnRecordingProgress and checks RecordingDuration every frame
while _isRecording; throttle these operations by accumulating Time.deltaTime and
only invoking the progress event and duration check at a lower frequency (e.g.,
every 0.1s or configurable interval). Add a private float accumulator and a
configurable float progressUpdateInterval, increment the accumulator with
Time.deltaTime each Update, and when it exceeds the interval reset the
accumulator, invoke OnRecordingProgress with current RecordingProgress, and then
perform the RecordingDuration >= _maxRecordingLength check and StopRecording()
if needed. Ensure the default interval is small enough for UI responsiveness but
avoids per-frame updates.


Expand All @@ -54,6 +75,10 @@ private void OnDestroy()

#region Public Methods

/// <summary>
/// 음성 녹음 시작
/// </summary>
/// <returns>녹음 시작 성공 여부</returns>
public bool StartRecording()
{
if (_isRecording)
Expand All @@ -73,10 +98,11 @@ public bool StartRecording()
{
_isRecording = true;
_recordingStartTime = Time.time;
_audioBuffer.Clear();

_recordingClip = Microphone.Start(null, false, _maxRecordingLength, _sampleRate);
// 최대 녹음 시간만큼 버퍼 할당
_recordingClip = Microphone.Start(_currentDevice ?? string.Empty, false, _maxRecordingLength, _sampleRate);

Debug.Log($"[AudioRecorder] 음성 녹음 시작됨 (최대 {_maxRecordingLength}초, {_sampleRate}Hz)");
OnRecordingStarted?.Invoke();

return true;
Expand All @@ -90,6 +116,10 @@ public bool StartRecording()
}
}

/// <summary>
/// 음성 녹음 중지
/// </summary>
/// <returns>처리된 AudioClip</returns>
public AudioClip? StopRecording()
{
if (!_isRecording)
Expand All @@ -101,18 +131,25 @@ public bool StartRecording()
try
{
_isRecording = false;
_recordingEndTime = Time.time;
float actualRecordingDuration = _recordingEndTime - _recordingStartTime;

Microphone.End(null);
Microphone.End(_currentDevice ?? string.Empty);

if (_recordingClip != null)
{
ProcessRecordingClip();
OnRecordingCompleted?.Invoke(_recordingClip);
AudioClip processedClip = ProcessRecordingClip(actualRecordingDuration);
if (processedClip != null)
{
Debug.Log($"[AudioRecorder] 음성 녹음 완료됨 ({actualRecordingDuration:F1}초, {processedClip.samples} 샘플)");
OnRecordingCompleted?.Invoke(processedClip);
OnRecordingStopped?.Invoke();
return processedClip;
}
}

OnRecordingStopped?.Invoke();

return _recordingClip;
return null;
}
catch (Exception ex)
{
Expand All @@ -123,72 +160,186 @@ public bool StartRecording()
}
}

public byte[] AudioClipToBytes(AudioClip audioClip)
/// <summary>
/// AudioClip을 WAV 바이트 배열로 변환
/// </summary>
public byte[] AudioClipToWavBytes(AudioClip audioClip)
{
if (audioClip == null)
return new byte[0];

return Array.Empty<byte>();
try
{
float[] samples = new float[audioClip.samples * audioClip.channels];
audioClip.GetData(samples, 0);

byte[] audioBytes = new byte[samples.Length * 2];
for (int i = 0; i < samples.Length; i++)
return WavEncoder.FromAudioClip(audioClip);
}
catch (Exception ex)
{
Debug.LogError($"[AudioRecorder] WAV 변환 실패: {ex.Message}");
return Array.Empty<byte>();
}
}

/// <summary>
/// 녹음 파일 저장 (디버깅용)
/// </summary>
public bool SaveRecordingToFile(AudioClip audioClip, string fileName = "recording")
{
if (audioClip == null)
{
Debug.LogError("[AudioRecorder] 저장할 AudioClip이 null입니다.");
return false;
}

try
{
byte[] wavData = AudioClipToWavBytes(audioClip);
if (wavData.Length == 0)
{
short sample = (short)(samples[i] * short.MaxValue);
BitConverter.GetBytes(sample).CopyTo(audioBytes, i * 2);
Debug.LogError("[AudioRecorder] WAV 데이터 변환 실패");
return false;
}

string filePath = System.IO.Path.Combine(Application.persistentDataPath, $"{fileName}.wav");
System.IO.File.WriteAllBytes(filePath, wavData);

return audioBytes;
Debug.Log($"[AudioRecorder] 녹음 파일 저장됨: {filePath} ({wavData.Length} bytes)");

return true;
}
catch (Exception ex)
{
Debug.LogError($"[AudioRecorder] AudioClip을 byte 배열로 변환 실패: {ex.Message}");
return new byte[0];
Debug.LogError($"[AudioRecorder] 파일 저장 실패: {ex.Message}");
return false;
}
}

/// <summary>
/// 사용 가능한 마이크 목록 반환
/// </summary>
public string[] GetAvailableMicrophones()
{
return Microphone.devices;
}

/// <summary>
/// 기본 마이크 반환
/// </summary>
public string GetDefaultMicrophone()
{
string[] devices = Microphone.devices;
return devices.Length > 0 ? devices[0] : string.Empty;
}

/// <summary>
/// 현재 마이크 설정
/// </summary>
public void SetMicrophone(string deviceName)
{
if (_isRecording)
{
Debug.LogError("[AudioRecorder] 녹음 중에는 마이크를 변경할 수 없습니다.");
return;
}

if (Array.Exists(Microphone.devices, device => device == deviceName))
{
_currentDevice = deviceName;
Debug.Log($"[AudioRecorder] 마이크 변경됨: {deviceName}");
}
else
{
Debug.LogWarning($"[AudioRecorder] 존재하지 않는 마이크: {deviceName}");
}
}

#endregion

#region Private Methods

private void ProcessRecordingClip()
/// <summary>
/// 마이크 초기화
/// </summary>
private void InitializeMicrophone()
{
string[] devices = Microphone.devices;
if (devices.Length > 0)
{
_currentDevice = devices[0];
Debug.Log($"[AudioRecorder] 마이크 초기화됨: {_currentDevice}");
}
else
{
Debug.LogError("[AudioRecorder] 사용 가능한 마이크가 없습니다.");
}
}

/// <summary>
/// 녹음된 AudioClip 처리
/// </summary>
private AudioClip? ProcessRecordingClip(float actualDuration)
{
if (_recordingClip == null)
return;
return null;

int recordedLength = Microphone.GetPosition(null);
if (recordedLength <= 0)
// 실제 녹음 시간을 기반으로 샘플 수 계산
int actualSamples = Mathf.RoundToInt(actualDuration * _sampleRate);

// 최대 샘플 수 제한 (버퍼 크기)
int maxSamples = _recordingClip.samples;
actualSamples = Mathf.Min(actualSamples, maxSamples);

Debug.Log($"[AudioRecorder] 녹음 데이터 처리 중 ({actualSamples}/{_recordingClip.samples} 샘플, {actualDuration:F1}초)");

if (actualSamples <= 0)
{
Debug.LogWarning("[AudioRecorder] 녹음된 데이터가 없습니다.");
return;
return null;
}

// 실제 녹음된 길이만큼만 새로운 AudioClip 생성
AudioClip processedClip = AudioClip.Create(
"RecordedAudio",
recordedLength,
actualSamples,
_recordingClip.channels,
_recordingClip.frequency,
false
);

float[] samples = new float[recordedLength * _recordingClip.channels];
float[] samples = new float[actualSamples * _recordingClip.channels];
_recordingClip.GetData(samples, 0);

// 노이즈 리덕션 적용
if (_enableNoiseReduction)
{
ApplyNoiseReduction(samples);
}

processedClip.SetData(samples, 0);

// 원본 AudioClip 정리하여 메모리 누수 방지
if (_recordingClip != null)
{
DestroyImmediate(_recordingClip);
}

_recordingClip = processedClip;

Debug.Log($"[AudioRecorder] AudioClip 생성 완료 ({_recordingClip.samples} 샘플, {_recordingClip.channels} 채널, {_recordingClip.frequency}Hz)");

return _recordingClip;
}

/// <summary>
/// 노이즈 리덕션 적용
/// </summary>
private void ApplyNoiseReduction(float[] audioData)
{
for (int i = 0; i < audioData.Length; i++)
{
if (Mathf.Abs(audioData[i]) < _silenceThreshold)
{
audioData[i] = 0f;
}
}
}

#endregion
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading