마음 같아서는 모든 키의 소리를 각각 녹음하고 싶었으나, 제작의 편의를 위해 크게 5가지로 나누어 소리를 녹음했다.
A: 정방형 비율에 가까운 일반 키
B: 엔터, Backspace, Shift 등 긴 키
C: Ctrl, Window 등 짧은 키
D: Tab, Caps Lock 등 중간 키
E: 스페이스바
각 녹음 파일의 잡음을 제거하고, 시작 및 끝 지점 (Duration)을 맞추어 추출한다. (재생 시 일정한 시간을 보장하기 위해)
지난 포스트에서 제작한 mainKeys 배열에 audio 항목을 추가하고, 각 키에 해당하는 오디오를 표시한다.
ESC와 FunctionKeys의 경우 소리가 모두 같다고 가정하여 배열에 작성하지 않고, 추후 함수에 "A" 로 통일했다.
export type AudioType = "A" | "B" | "C" | "D" | "E"; // 반드시 이 중 하나여야함을 명시
interface Key {
label: string;
audio: AudioType;
extraClass?: string;
}
const mainKeys: Key[][] = [
...
[
{ label: "CTRL", audio: "C", extraClass: "w-[8%]" },
{ label: "WIN", audio: "C", extraClass: "w-[7%]" },
{ label: "ALT", audio: "C", extraClass: "w-[7%]" },
{ label: "SPACE", audio: "E", extraClass: "w-[38.6%]" },
{ label: "FN", audio: "C", extraClass: "w-[7.2%]" },
{ label: "CTRL", audio: "C", extraClass: "w-[7.5%] mr-[3%]" },
{ label: "←", audio: "A", extraClass: "w-[5.6%]" },
{ label: "↓", audio: "A", extraClass: "w-[5.7%]" },
{ label: "→", audio: "A", extraClass: "w-[5.7%]" },
],
]
이제 오디오 Keys 컴포넌트에 오디오 파일을 불러오고, 오디오를 호출해보자.
import AAudio from "../assets/audio/A.mp3";
import BAudio from "../assets/audio/B.mp3";
import CAudio from "../assets/audio/C.mp3";
import DAudio from "../assets/audio/D.mp3";
import EAudio from "../assets/audio/E.mp3";
type AudioType = "A" | "B" | "C" | "D" | "E";
...
// 실제 오디오 파일과 AudioType을 연결
const audioMap: { [key in AudioType]: string } = { // AudioType에 있는 요소 중 하나임을 명시
A: AAudio,
B: BAudio,
C: CAudio,
D: DAudio,
E: EAudio,
};
const playHandler = (audio: AudioType) => {
const audioFile = audioMap[audio]; // 지정한 오디오 파일을 인자에 맞게 가져옴(A~E 중 하나)
const audioElement = new Audio(audioFile); //audioFile 경로를 사용하여 새로운 Audio 객체 생성
audioElement.play(); // 함수 실행
};
Audio 객체란?
- JavaScript에서 제공하는 웹 오디오 API의 일부이다,
- new Audio(audioFile) 처럼 파일 경로, 또는 URL을 인자로 받아 새로운 Audio 객체를 생성한다.
- .play(), .pause(), volume, currentTime 등 메소드를 제공한다.
이제 이 핸들러 함수를 각 키에 연결하면 된다.
타이핑 버전, 클릭 버전, 호버 버전을 각각 만들 예정이지만, 우선 호버 (mouseEnter)으로 테스트해본다.
<div className="key-main-container">
{mainKeys.map((row, rowIndex) => (
<div className="key-main-row" key={rowIndex}>
{row.map((label, labelIndex) => (
<button
onMouseEnter={playHandler(label.audio)} // 핸들러 함수 연결
className={`main-keys ${label.extraClass || ""}`}
key={labelIndex}
>
<span>{label.label}</span>
</button>
))}
</div>
))}
</div>
이 상태에서 키보드에 마우스를 올려보니, 다음과 같은 에러가 콘솔창에 나타났다.
위 내용은 쉽게 말해서 유저의 승인 없이는 브라우저에서 소리를 자동 재생할 수 없다는 뜻이다.
브라우저에서 예상하지 못한 소리가 갑작스럽게 재생된다면 사용자의 경험을 저해할 수 있다.
따라서 사용자가 이를 인지하고, 어떤 액션을 취해야만 소리가 재생될 수 있다.
하다 못해 아무데나 클릭이라도 해야 재생된다.
우선 테스트이니, 화면을 한번 클릭하고 키 위에 마우스를 올려보면 오디오가 잘 재생되는 것을 확인할 수 있다.
오디오 파일의 앞 부분을 0.5초 미만으로 축소했음에도 불구하고, 매번 소리가 조금 늦게 재생되는 것을 느꼈다.
파헤쳐 보니, 핸들러 함수가 실행될 때마다 오디오 파일을 다시 로드하고, 새로운 Audio 객체를 생성하고 있었다.
파일 자체가 매우 작지만, 키보드 특성 상 빠르고 연속적으로 유저 상호작용이 일어날 때를 고려하면-
미리 오디오 파일을 로드해서 캐시하는 장점이 사라지고, 앞선 파일과 연속 재생이 어려워지며, 리소스 낭비로 이어진다.
이 객체를 최초 한 번만 생성하고 컴포넌트를 리렌더링 하더라도 유지하는 방법이 없을까?
이런 문제는 useRef를 통해 해결할 수 있다.
아래 두 코드를 비교해보자.
//기존 코드
const audioMap: { [key in AudioType]: string } = {
A: AAudio,
B: BAudio,
C: CAudio,
D: DAudio,
E: EAudio,
};
const playHandler = (audio: AudioType) => {
const audioFile = audioMap[audio];
const audioElement = new Audio(audioFile);
audioElement.play();
};
//수정된 코드
const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement }>({ // 엄격한 타입 정의. 생성된 모든 Audio객체는 HTMLAudioElement 타입을 갖는다.
A: new Audio(AAudio), // 최초 객체 생성하여 useRef에 저장
B: new Audio(BAudio),
C: new Audio(CAudio),
D: new Audio(DAudio),
E: new Audio(EAudio),
});
// 현재 오디오 객체 확인
useEffect(() => {
console.log(audioMap.current)
}, []);
const playHandler = (audio: AudioType) => {
const audioElement = audioMap.current[audio];
audioElement.currentTime = 0; // 연속 재생을 위해 시간 초기화
audioElement.play();
console.log("재생", audio);
};
이렇게 구성하면 객체는 최초 한 번만 생성되기 때문에 매번 Audio 객체가 새로 생성되는 것을 막을 수 있다.
다만, 여러 키를 계속해서 호버했더니 오디오가 심하게 끊긴다.
게다가 오히려 매번 객체를 생성할 때는 빠르게 입력하는 경우, 앞선 오디오가 끝나기도 전에 다음 오디오가 정상적으로 실행됐는데,
수정한 코드에서는 직전에 실행한 오디오가 모두 실행되기 전에는 다음 오디오가 재생되지 않았다.
키보드 특성 상 빠른 타이핑과 샷건 등 여러 키가 거의 동시에 눌리는 상황에도 부드러운 소리 재생을 원했다.
생성된 오디오 객체가 각각 하나 뿐이기 때문에 부자연스러운 재생이 이루어진 것이다.
그렇다면 오디오 객체를 여러개 생성하여 배열에 저장하고, 미처 다 재생되기 전에 또 재생하는 상황에서 그 다음 인덱스를 재생하는 방법은 어떨까?
우선 최초 Audio 객체를 여러개 중복으로 생성한다. 넉넉히 5개씩 생성해보았다.
const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement[] }>({ //배열 형태임을 추가 명시
A: [new Audio(AAudio), new Audio(AAudio), new Audio(AAudio), new Audio(AAudio), new Audio(AAudio)],
B: [new Audio(BAudio), new Audio(BAudio), new Audio(BAudio), new Audio(BAudio), new Audio(BAudio)],
C: [new Audio(CAudio), new Audio(CAudio), new Audio(CAudio), new Audio(CAudio), new Audio(CAudio)],
D: [new Audio(DAudio), new Audio(DAudio), new Audio(DAudio), new Audio(DAudio), new Audio(DAudio)],
E: [new Audio(EAudio), new Audio(EAudio), new Audio(EAudio), new Audio(EAudio), new Audio(EAudio)],
});
그리고 현재 재생할 오디오에 대한 인덱스를 저장할 객체를 만들어준다.
const audioIndex = useRef<{ [key in AudioType]: number }>({
A: 0,
B: 0,
C: 0,
D: 0,
E: 0,
});
마지막으로 이 인덱스를 순환하며 playHandler를 재생한다.
const playHandler = (audio: AudioType) => {
const audioArray = audioMap.current[audio]; //useRef로 생성한 객체의 오디오 가져오기
const index = audioIndex.current[audio]; //해당 인덱스
// 현재 인덱스와 일치하는 오디오 객체를 재생
const audioElement = audioArray[index];
audioElement.currentTime = 0; // 초기화 해주지 않으면 그 다음 재생되는 오디오가 같은 시점에 resume된다
audioElement.play();
// 다음 인덱스로 이동. 마지막이라면 0으로 돌아가며 순환
audioIndex.current[audio] = (index + 1) % audioArray.length;
};
이제 그 전보다 딜레이가 많이 줄어든 것을 확인할 수 있다.
물론, 아주 마우스 커서를 아주 빠르게 움직이니 어느 정도 렉이 걸리는데... 어떻게 해야 더더욱 부드럽게 만들 수 있을지 고민해보아야겠다.
추가로, 위 객체 복제본을 생성하는 코드를 조금 더 효율적으로 작성할 수도 있다.
const createAudioArray = (audioSrc: string, count: number) =>
Array.from({ length: count }, () => new Audio(audioSrc));
const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement[] }>({
A: createAudioArray(AAudio, 10),
B: createAudioArray(BAudio, 10),
C: createAudioArray(CAudio, 10),
D: createAudioArray(DAudio, 10),
E: createAudioArray(EAudio, 10),
});
'React.ts' 카테고리의 다른 글
리액트 가상 키보드 만들기 - Audio 객체 (추가) (0) | 2024.10.28 |
---|---|
리액트 가상 키보드 만들기 - 키보드 매핑하기 1 (0) | 2024.10.28 |
리액트 가상 키보드 만들기 - 레이아웃 및 디자인, 배열과 map을 활용한 키 매핑 (3) | 2024.10.25 |
Firebase 정보 새로고침 시 초기화 되는 오류 (1) | 2024.09.19 |
GSAP 활용하여 ScrollEvent 쉽게 구현하기 - 2 (0) | 2024.08.12 |