초성 퀴즈는 조용히 하는 게임이 아니다.
화면에 "ㅎ ㄹ ㅇ"이 뜨면 누군가 먼저 "호랑이!" 하고 외친다. 그다음에야 폰을 집어 입력칸을 누르고, 자판을 띄우고, 한 글자씩 친다. 그 사이 시간은 흘러간다. 입은 이미 답을 냈는데 손이 따라가지 못하는 셈이다.
그래서 입력칸 옆에 「말하기」 버튼을 달았다. 누르고 외치면 그 말이 그대로 답이 된다. 들은 말이 틀렸으면 자판으로 쳤을 때처럼 「땡!」과 함께 무엇을 알아들었는지 보여 준다.
플러그인 없이 인식기 부르기
Android 에는 android.speech.SpeechRecognizer 가 있다. 화면을 띄우지 않고 뒤에서 듣다가 결과를 콜백으로 돌려주는 인식기다. 구글 음성 입력 창을 띄우는 인텐트 방식도 있지만, 그러면 게임 위에 다른 화면이 덮이고 Unity 가 뒤로 밀린다. 퀴즈 타이머가 도는 중에 그러면 곤란하다.
Unity 에서 Java API 를 부르는 흔한 방법은 Java 로 작은 플러그인을 짜서 넣는 것이다. 이번에는 그러지 않았다. AndroidJavaClass, AndroidJavaObject 로 Java 객체를 C# 에서 직접 만들고 부르면 외부 패키지도 Java 파일도 없이 끝난다. 듣기를 시작하는 부분은 이렇다.
using (var cls = new AndroidJavaClass("android.speech.SpeechRecognizer"))
_recognizer = cls.CallStatic<AndroidJavaObject>("createSpeechRecognizer", activity);
_recognizer.Call("setRecognitionListener", new Listener(session));
using (var intent = new AndroidJavaObject("android.content.Intent", "android.speech.action.RECOGNIZE_SPEECH"))
{
intent.Call<AndroidJavaObject>("putExtra", "android.speech.extra.LANGUAGE_MODEL", "free_form");
intent.Call<AndroidJavaObject>("putExtra", "android.speech.extra.LANGUAGE", "ko-KR");
intent.Call<AndroidJavaObject>("putExtra", "android.speech.extra.MAX_RESULTS", 5);
intent.Call<AndroidJavaObject>("putExtra", "android.speech.extra.PARTIAL_RESULTS", true);
_recognizer.Call("startListening", intent);
}
한국어로, 후보는 최대 다섯 개, 듣는 도중의 중간 결과도 달라고 한다. 중간 결과는 입력칸 아래 안내 줄에 흘려보낸다. 말하는 동안 글자가 따라 올라오면 "듣고 있구나" 하는 게 보인다.
콜백을 받는 RecognitionListener 는 Java 인터페이스다. 이건 AndroidJavaProxy 를 상속해서 C# 쪽에 구현한다. 인터페이스 메서드를 하나하나 C# 메서드로 만들 수도 있지만, Invoke(string methodName, object[] args) 하나를 덮어쓰고 이름으로 나눠 받는 쪽을 골랐다. 이 인터페이스는 Android 버전이 오를 때마다 메서드가 늘었다. onSegmentResults, onLanguageDetection 같은 것들이다. 이름으로 받으면 쓰는 것 셋(onResults, onPartialResults, onError)만 처리하고, 나머지는 인자로 넘어온 Java 객체만 정리한 뒤 넘긴다. onRmsChanged 는 수십 ms 마다 불리니 여기서 아무것도 안 하는 게 중요했다.
매니페스트도 두 군데 손봤다. 빌드 때 매니페스트에 권한을 끼워 넣는 에디터 스크립트가 이미 있었는데, 거기에 RECORD_AUDIO 를 추가했다. 그리고 <queries> 에 android.speech.RecognitionService 를 넣었다. Android 11 부터는 앱이 다른 앱의 서비스를 그냥 볼 수 없다. 이게 없으면 음성 인식 앱이 멀쩡히 깔려 있어도 isRecognitionAvailable 이 거짓을 돌려준다.
스레드가 셋이었다
이 기능에서 가장 오래 들여다본 건 인식 자체가 아니라 "이 코드는 어느 스레드에서 도는가"였다.
Unity 스크립트는 Unity 메인 스레드에서 돈다. 그런데 이건 Android 앱의 UI 스레드가 아니다. SpeechRecognizer 는 앱의 메인(UI) 스레드에서만 만들고 다뤄야 한다. 그러니 C# 에서 바로 createSpeechRecognizer 를 부르면 안 되고, 액티비티의 runOnUiThread 에 AndroidJavaRunnable 로 감싸서 넘겨야 한다. 위 코드 조각은 전부 그 안에서 돈다. 인식기를 정리하는 cancel, destroy 도 같은 길로 보낸다.
반대 방향도 있다. 인식 결과 콜백은 Android UI 스레드에서 들어온다. 거기서 바로 Unity 의 Text 를 바꾸거나 게임 판정을 부르면 안 된다. Unity 객체는 Unity 메인 스레드에서만 만져야 한다.
다행히 이 문제를 푸는 도구는 이미 있었다. 같이 하기(LAN 멀티)를 만들 때 소켓 스레드에서 받은 메시지를 메인 스레드로 넘기려고 NetPump 를 만들어 뒀다. 아무 스레드에서나 Post 로 작업을 넣으면 다음 Update 에서 꺼내 실행하는 큐다. 인식기 콜백도 그대로 여기에 태웠다.
case "onResults":
{
var bundle = args.Length > 0 ? args[0] as AndroidJavaObject : null;
var texts = Texts(bundle); // Java 쪽에서 글자만 꺼낸다
if (bundle != null) bundle.Dispose();
int s = _session;
NetPump.Post(() =>
{
if (texts.Count > 0) Finish(s, texts, null);
else Finish(s, null, ErrorText(7));
});
return null;
}
Java 객체(Bundle)는 Android 스레드에서 바로 풀어 C# 문자열 목록으로 바꾸고, 메인 스레드로는 그 목록만 넘긴다. Java 객체는 받은 자리에서 바로 정리한다.
여기서 _session 이 하나 더 필요했다. 듣는 중에 정답이 공개되거나, 시간이 끝나거나, 다른 화면으로 나가면 듣기를 멈춘다. 그런데 이미 Android 쪽에서 출발한 콜백은 멈추지 않고 큐로 들어온다. 그게 다음 문제의 화면에서 실행되면 이전 문제의 답이 다음 문제에 제출된다. 그래서 듣기를 시작하거나 멈출 때마다 회차 번호를 올리고, 콜백은 출발할 때의 번호를 들고 온다. Finish 는 번호가 지금과 다르면 아무것도 하지 않는다. 늦게 도착한 결과는 조용히 버려진다.
권한도 같은 구조다. RECORD_AUDIO 는 이 앱에서 유일한 위험 권한이라, 처음 「말하기」를 누를 때 Unity 의 Permission.RequestUserPermission 으로 묻는다. 허용·거부 콜백도 NetPump.Post 로 메인 스레드에 넘기고, 허용이 돌아왔을 때 아직 같은 회차로 듣는 중일 때만 인식기를 시작한다. 거부하면 "마이크 권한이 있어야 말로 입력할 수 있어요"를 띄우고 끝이다. 자판은 그대로 쓸 수 있다.
다섯 개의 후보 중 무엇을 낼까
인식기는 답을 하나만 주지 않는다. 그럴듯한 순서로 최대 다섯 개를 준다. "ㄱ ㅊ ㅉ ㄱ" 문제에 "김치찌개"라고 말하면 첫 후보가 "김치 찌개"로 띄어 올 수도 있고, 셋째쯤에 "김치찌게"가 섞여 있을 수도 있다.
정답 판정 자체는 손대지 않았다. 원래 규칙은 입력의 공백을 지운 뒤, 출제 단어와 같거나, 초성이 같으면서 내장 사전에 있는 단어면 정답이다. 음성 입력이 할 일은 다섯 후보 중 이 판정에 넣을 하나를 고르는 것뿐이다.
public static string PickSpoken(IList<string> candidates, string chosung)
{
var all = All; // 사전 로드
string first = "", sameChosung = "";
foreach (var c in candidates)
{
var sb = new StringBuilder();
foreach (char ch in c ?? "")
if (char.IsLetterOrDigit(ch)) sb.Append(ch);
var w = sb.Length > 20 ? sb.ToString(0, 20) : sb.ToString();
if (w.Length == 0) continue;
if (first.Length == 0) first = w;
if (Chosung(w) != chosung) continue;
if (_set.Contains(w)) return w;
if (sameChosung.Length == 0) sameChosung = w;
}
return sameChosung.Length > 0 ? sameChosung : first;
}
먼저 글자와 숫자만 남긴다. 띄어쓰기도, 인식기가 붙이는 물음표나 느낌표도 지운다. 그다음 우선순위는 셋이다. 초성이 같고 사전에도 있는 말, 초성만 같은 말, 그것도 없으면 첫 후보. 첫 후보는 틀린 답이 된다는 걸 알면서 낸다. 그래야 화면에 「땡! 들은 말」이 뜨고, 말한 사람이 "아, 저렇게 알아들었구나" 하고 다시 누를 수 있다. 아무것도 안 내면 버튼만 눌렀다 꺼진 것처럼 보인다.
이 함수가 출제 단어가 아니라 문제의 초성을 받는다는 점이 중요하다. 이유는 같이 하기에 있다.
같이 하기는 아무것도 몰랐다
같이 하기에서는 모두가 자기 폰으로 동시에 답을 낸다. 판정은 방장 폰이 한다. 게스트 폰은 정답을 모른다. 받는 건 문제의 초성과 분류뿐이다. 방장 권위로 짜면서 정답은 처음부터 게스트에게 보내지 않았다.
그래서 후보를 고르는 기준이 정답이면 게스트는 쓸 수 없다. 초성이면 쓸 수 있다. 게스트 폰도 받은 초성으로 다섯 후보 중 하나를 골라, 자판으로 친 답과 똑같은 답 메시지에 실어 방장에게 보낸다. 방장은 그게 자판으로 친 건지 말한 건지 모르고, 알 필요도 없다. 틀리면 방장이 돌려보내는 「땡」 메시지에 그 글자가 실려 와서 게스트 화면에도 들은 말이 뜬다.
덕분에 같이 하기 규약 버전을 올리지 않았다. 규약이 바뀌면 모든 폰이 같은 버전이어야 방에 들어올 수 있다. 음성 입력이 들어간 폰과 아직 업데이트하지 않은 폰이 한 방에서 같이 할 수 있다는 뜻이다. 화면 쪽 코드도 자판 제출과 음성 제출이 하나의 Submit 으로 모인다. 로컬이면 지금 차례인 사람으로, 방장이면 자기 자신으로 바로 판정하고, 게스트면 방장에게 보낸다.
같은 자리에서 정답을 소리 내 외치면 옆 사람도 듣고, 옆 사람 폰도 알아들을 수 있다. 이건 고민하다가 그대로 두었다. 먼저 외친 사람의 답을 남이 낚아채는 것도 술자리 게임에서는 재미에 가깝다. 실제 모임에서 문제가 되면 같이 하기에서만 끄거나 자기 차례에만 허용하는 쪽을 보기로 했다.
말할 수 없는 곳의 버튼
이 게임은 Android 말고도 PC 와 WebGL 로 빌드된다. 거기엔 SpeechRecognizer 가 없다. Android 라도 음성 인식 서비스가 없는 폰이 있다. 그래서 SpeechInput.Supported 하나로 갈랐다. Android 에서는 isRecognitionAvailable 을 한 번 물어 기억하고, PC 와 WebGL 은 컴파일 단계에서 거짓이다. 거짓이면 버튼을 아예 만들지 않는다. 입력칸과 제출 버튼은 원래 폭으로 돌아가고, 지원되면 입력칸을 조금 줄여 [입력칸][말하기][제출] 세 칸을 놓는다. 회색으로 눌리지 않는 버튼을 두는 것보다 없는 게 낫다고 봤다.
에디터는 예외로 참(true)을 돌려준다. 에디터에는 인식기가 없지만, 버튼을 누르고 「듣는 중」으로 바뀌고 중간 결과가 올라오고 판정까지 가는 화면 흐름은 매번 폰에 올리지 않고 보고 싶었다. 그래서 흉내를 냈다. 0.5초 뒤에 정답의 앞 절반을 중간 결과로, 1.5초 뒤에 정답 전체를 들은 말로 돌려준다. NetPump.Tick 에 매 프레임 확인하는 작은 함수를 걸어 두는 방식이다. 게스트일 때는 정답을 모르니 빈 말을 넘기고, 그러면 "잘 못 알아들었어요"가 뜬다. 이것도 실제 실패 화면을 확인하는 데 쓸모가 있었다.
듣는 동안엔 배경음악을 멈춘다. 칩튠 루프가 폰 스피커로 나오면 마이크가 그걸 같이 듣는다. 듣기가 끝나면 멈춘 자리부터 다시 튼다. 인식 오류 코드도 사람 말로 바꿨다. 못 알아들었으면 다시 말해 달라고, 네트워크 오류면 음성 인식에 인터넷이 필요하다고, 한국어 인식이 없는 폰이면 그렇다고. 앱은 소리를 받지 않는다. 소리를 글자로 바꾸는 건 폰에 깔린 음성 인식 서비스고, 앱은 그 글자만 받아 판정하고 버린다. 들은 말을 로그로도 찍지 않는다. 개인정보 처리방침에 마이크 항목을 새로 넣으면서 이 경계를 그대로 적었다.
실기기 확인은 갤럭시 Z 플립3(Android 15)에서 했다. 처음 누를 때 권한 창이 뜨고, 허용하면 바로 듣고, 말한 답이 판정까지 갔다. 기기 로그에 SpeechInput 경고나 크래시는 없었다. 이 기능을 담은 버전은 프로덕션에 나가 있던 버전 위로 올라가는 업데이트로 나갔다.
이제 퀴즈 판 앞에서 "호랑이!"라고 외치면, 그 소리가 그대로 점수가 된다.
정식 출시와 함께 이 연재도 여기서 마무리한다. 다음 버전에는 출시 직후 플레이어들이 남겨 준 의견, 이름 바꾸기가 불편하다는 이야기와 캐릭터를 더 골라 쓰고 싶다는 이야기가 먼저 들어간다. 마이크 권한을 묻기 전에 앱 안에서 한 번 미리 설명하는 화면도 그 목록에 있다.