海外との商談が増えて、困っていたこと
僕はエンジニアではありません。株式会社コホマダという会社で、海外企業の日本市場参入と、日本企業の海外展開を支援しています。前回、Claude Code で自社サイトを作った話を書きました。今回はその続きではなく、まったく別のものを作った話です。
仕事柄、海外との打ち合わせが多くなりました。英語が中心ですが、ポルトガル語の商談もあります。先日は101分、通訳を挟んで話しました。
先に断っておくと、これから作るものは「翻訳がないと商談できない」から作ったのではありません。通訳が必要な場面には通訳に入ってもらいますし、英語の打ち合わせは普通にやっています。そこは仕事なので、機械に任せる話ではない。
困っていたのは、別のところでした。
通訳が訳さない部分がある
直近でポルトガル語の案件がありました。通訳に入ってもらったので、本題は問題なく理解できます。そこは通訳の仕事で、正確にやってもらえます。
ただ、通訳が訳すのは本題です。
話の合間にこぼれる一言や、本題から少しそれた雑談まではカバーされません。それは通訳の職分の外なので、当然のことです。
問題は、そこに大事なものが混ざることでした。
「うちも似たことで困っていて」「前に一度やって失敗した」。そういう一言は、たいてい議題から外れたところで出ます。日本語の商談なら確実に拾って、その場で掘り下げているはずの場面です。
それが、訳されないまま流れていく。あとで議事録を見ても、そこは要約されて消えているか、そもそも記録に残っていません。
拾いたかったのは、ここでした。
議事録は残る。でも、その場では読めない
打ち合わせの録画と文字起こしは、ツールを入れて残しています。終わったあとに見返せば、何を話したかは分かる。
ただ、それは終わってから読むものです。
商談で本当に必要なのは、相手が話しているその瞬間に「いま何を求められたのか」を掴むことです。議事録は、そこには間に合いません。
そして外国語だと、この「その場で掴む」が一段むずかしくなる。聞き取ることに意識を持っていかれて、意味を組み立てる余裕が削られる。理解が半歩遅れて、こちらが考えている間に次の話に進んでいる。
トーンは、文字にならない
もうひとつ。同じ言葉でも、言い方で意味が変わります。
「それは難しいですね」を、切り捨てるように言ったのか、迷いながら言ったのか。後者なら、まだ余地があります。前者なら、そこは引くべきところです。
耳では、その差は分かります。日本語ならほぼ無意識に処理している情報です。ところが外国語だと、言葉を追うので手一杯になって、トーンまで意識が回らないことがある。
だから、言葉のほうを機械に引き受けてもらいたかった。文字は画面に出ているから、耳は相手の言い方に集中できる。そういう状態を作りたかったんです。
認識のズレに、あとから気づく
そしてこれが一番厄介でした。
その場では通じたつもりでいて、後日のメールで噛み合っていないことが分かる。「そちらが確認すると言いましたよね」「いえ、こちらが確認すると申し上げました」。
これは語学力の問題ではありません。省略された主語や、曖昧なまま流した条件が、あとで別の意味で立ち上がってくる。日本語同士でも起きることが、外国語だと確率が上がるだけです。
議事録を見返せば、どちらが正しいかは分かります。ただ、そのときにはもう一往復増えている。その場で「いま、こう受け取りましたが合っていますか」と確認できていれば、起きなかった話です。
欲しかったのは、補助輪でした
つまり僕が欲しかったのは、通訳の代わりでも、議事録の代わりでもありません。
その場で、いま言われたことを目で確認できる状態です。耳は相手の言い方に向けたまま、言葉のほうは画面で拾う。理解がずれていれば、その場で聞き返せる。
補助輪です。走るのは自分で、外れかけたときに支えてくれるもの。
構成としては、こうなります。
イヤホンで相手の声を普通に聞きながら、同じ音を裏で拾って、日本語にして画面に出す。
作ってみたら、動きました。ただ、そこまでに壁が5枚ありました。全部 macOS の壁で、コードの問題ではありませんでした。この記事は、その5枚の話です。
最初に、何を諦めたか
「ブラウザでできないの?」と最初に思いました。
できません。
macOS のブラウザは、自分のタブの音しか取れません。Zoom や Teams で鳴っている音には手が届かない。これは OS の制約なので、実装をどう変えても回避できません。
なので構成はこうなりました。
Zoom / Meet / Teams の音
│ ← ここだけネイティブ(Swift)
▼
音声の取り込み+文字起こし
│ ローカルのTCP
▼
Node のサーバー(依存パッケージ 0)
│
▼
ブラウザの画面 ← 自分が見るのはここだけ
画面はブラウザです。音の取得だけ、どうしてもネイティブにせざるを得なかった。
壁① 「音を録る」が「画面を録る」に化ける
macOS でシステム音声を取る方法はいくつかあります。BlackHole のような仮想オーディオデバイスを入れる方法が有名で、実際このMacにはもう入っていました。
ただこれ、出力先を「複数出力装置」に切り替える必要があります。 イヤホンの音量調整が効かなくなったりする。会議の直前にオーディオ設定をいじるのは、正直こわい。
そこで ScreenCaptureKit を使いました。出力先を一切変えずに、鳴っている音だけ取れます。 聞こえ方が変わらないのが決定的でした。
ただし、ここで妙なことが起きます。
macOS はシステム音声の取得を「画面収録」の権限に含めています。
映像は要らないので、取得サイズは 2×2 ピクセルにしました。
let cfg = SCStreamConfiguration()
cfg.capturesAudio = true
cfg.width = 2; cfg.height = 2 // 映像は使わないので最小
実質なにも映していません。それでも OS から見れば「画面収録するアプリ」なので、メニューバーにインジケータが点きます。
これは仕様なので、受け入れるしかありませんでした。
壁② Info.plist は、置くだけでは読まれない
文字起こしには macOS 内蔵の Speech フレームワークを使いました。英語と日本語は端末内で完結するので、音声が外に出ません。商談の中身を扱う以上、ここは譲れませんでした。
利用には許可が要ります。Info.plist に説明文を書く、というのは調べればすぐ出てきます。
書きました。落ちました。
This app has crashed because it attempted to access privacy-sensitive
data without a usage description. The app's Info.plist must contain an
NSSpeechRecognitionUsageDescription key...
書いてあるのに「書け」と言われる。しばらく意味が分かりませんでした。
答えは、実行ファイルの中に焼き込む必要があるというものでした。.app/Contents/Info.plist に置いただけでは、この起動のされ方だと読まれません。
swiftc -O -parse-as-library MeetingCore.swift \
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist \
-Xlinker Info.plist \
-o "$APP/Contents/MacOS/MeetingCore"
この -sectcreate の一行です。これを消すと、また同じところで落ちます。
壁③ ターミナルから起動すると、必ず落ちる
焼き込んでも落ちました。
いろいろ試した結果、こういうことでした。
./MeetingCore.app/Contents/MacOS/MeetingCore # 落ちる
open MeetingCore.app # 動く
アプリとして起動されたときだけ、許可が通ります。 ターミナルから直接叩くと、同じバイナリでも拒否されます。
これは地味に困りました。open で起動すると、標準出力が拾えないからです。文字起こしの結果をどうやって受け取るのか。
結局、ローカルの TCP に流すことにしました。
// 音声認識の許可は、LaunchServices 経由で .app として
// 起動したときだけ通る。つまり stdout は使えない。
// なので結果はローカルの TCP へ流す。
Sink.shared.connect(port: port)
設計がねじれているように見えますが、そうせざるを得なかったという話です。
壁④ 許可ダイアログが、出てこない
裏方アプリにしたかったので LSUIElement を true にしていました。Dock に出ないアプリにする設定です。
起動しました。何も起きません。エラーも出ません。ただ黙って止まる。
原因は、許可を求めるダイアログが前面に出てこないことでした。裏方アプリなので、macOS がダイアログを前に持ってこない。ユーザーは何が起きているか分からず、アプリは永遠に応答を待ち続ける。
false に戻したら、普通にダイアログが出ました。
壁⑤ ビルドし直すと、許可が消える
これが一番きつかった。
コードを1文字直してビルドし直すと、画面収録も音声認識も、許可が全部外れます。
理由は署名です。このMacには署名用の証明書がありません。
$ security find-identity -v -p codesigning
0 valid identities found
証明書がないとアドホック署名になります。これは中身から指紋を作る方式なので、ビルドするたびに指紋が変わり、macOS が「別のアプリ」と判断して許可を破棄します。
つまり、直す → ビルド → 許可を取り直す、の無限ループです。1日に2回はまりました。
根本的には自己署名証明書を作れば解決します。ただ、その日はそこまで手が回らなかったので、調整しそうな値を全部起動引数に逃がしました。
--silence-gap 1.1 文の区切りとみなす無音の長さ
--speech-floor -50 声とみなす音量
--locale en-US 相手の言語
作り直さずに調整できるようにする。 対症療法ですが、これで止まらなくなりました。
動いたあとに出た、本当の問題
5枚の壁を越えて、音が取れて、文字にもなりました。
なのに、翻訳が出ない。
音は認識している。画面に英語も流れている。でも日本語にならない。
原因は、文の区切りでした。
音声認識は、喋っている途中の状態を細かく返してきます。それを「いつ1文として確定させるか」を自分で決めないといけない。最初は「無音が続いたら」という判定にしていました。
会議は間があるので、これで足りると思っていました。足りませんでした。相手が喋り続けると、いつまでも確定しない。翻訳に渡す単位が決まらない。
しかも無理に切ると、こういうものが出てきます。
a dozen all king the
これを翻訳すると「ダースオールキング」になります。壊れた入力からは、壊れた訳しか出てきません。
直したのは、文末を見るという一点でした。
// 認識器は句読点を付けてくれる。それを区切りの合図に使う。
const ENDS_SENTENCE = /[.!?。!?]["')\]]?\s*$/;
if (ENDS_SENTENCE.test(fresh) && !tooShort(fresh)) commit();
間だけで判断していた頃は、話し終えてから1.3秒待たされていました。文末で即座に切るようにしたら、待ち時間が消えました。
[123ms] Could you send us a quotation by Friday
→ 金曜日までに見積書を送っていただけますか
[492ms] We also need to confirm the delivery timeline before we sign
→ 契約に署名する前に納期を確認する必要もあります
速度の問題だと思っていたものが、区切り方の問題でした。
同じ文が2回出た
もうひとつ。確定した文が、次の確定にも丸ごと混ざって出てくることがありました。
「どこまで確定済みか」を文字数で覚えていたのが原因です。音声認識は、後から前の語を直します。句読点が付いたり、大文字になったり。すると前方一致が崩れて、「全部が新しい文だ」と誤判定する。
単語数で持つように変えたら、直りました。
// 文字列の前方一致で見ていたら、同じ発言が2回出た。
// 認識器は後から前の語を直すので、単語数で持つ。
if (words.length < SEG.done) SEG.done = 0;
const fresh = words.slice(SEG.done).join(' ');
こういうのは、動かしてみるまで分かりませんでした。
一番ヒヤリとしたこと
技術の話ではありません。
自分の日本語を英語にする機能をテストしていたときです。
入力:できます。ただ、納期については一度社内で確認させてください。
訳 :However, please confirm the delivery date within the company.
読めますか。
「こちらが社内で確認します」が、「そちらが社内で確認してください」になっています。
日本語は主語を省きます。機械翻訳はそれを勝手に補うので、誰の行為かがひっくり返る。 これを商談で送っていたら、話が完全にすれ違っていました。
対策として、生成した英文をもう一度日本語に戻して、並べて表示するようにしました。
ENGLISH
We can accommodate you. However, we will confirm the delivery date
within our company.
日本語に戻すと:対応させていただきます。但し、納期につきましては
弊社にて確認させていただきます。
送る前に、自分で検算できる。
ちなみに主語を入れて「当社内で確認いたします」と書いたら、一発で正しく訳されました。AIの問題ではなく、入力の問題でした。
できたもの
core/MeetingCore.swift 356行 音声の取り込みと文字起こし
app/server.mjs 411行 区切り・翻訳・配信
app/ui.html 350行 画面
合計およそ1,100行。npm の依存パッケージは0個です。
環境は macOS 15.7、Intel の MacBook Pro。Xcode は入れていません(CommandLineTools だけでビルドできました)。
文字起こしは macOS 内蔵なので、追加費用はかかりません。英語と日本語は端末内で完結します。
画面はこうなっています。左に相手の発言と日本語訳、右に自分の返答と、その英語・ポルトガル語。上から下へ会話として読めます。
これから
正直に書いておくと、これは自分が困ったから作ったもので、商品として作ったものではありません。
いまの翻訳部分は、正式なAPIを使っていません。自分ひとりで使う分には問題ありませんが、人に渡すものにするなら、ここは正規のAPIに差し替える必要があると思っています。設計は差し替えられる形にしてあるので、作業自体は大きくありません。
もうひとつ。ポルトガル語の音声認識は端末内で完結せず、外に出ます。英語と日本語は出ません。自分の商談なら自分で判断すればいい話ですが、他社の商談を扱うものとして渡すなら、ここは説明しないといけない部分です。
そのうえで。
同じ困り方をしている方は、思ったより多いのかもしれない、とは感じています。海外とやり取りしていて、通訳を挟むほどではないけれど、相手の一言は確実に取りたい。そういう場面です。
将来的には、きちんと作り直して人にお渡しできる形にできたら面白いな、とは思っています。ただ現状は個人用の道具で、そのまま渡せるものではありません。API連携のところを含めて、見直すことがまだいくつもあります。
もし同じことで困っている方がいたら、コメントで教えてください。どこから手をつけるかの参考にします。
おわりに
エンジニアではない人間が、こういうものを作れる時代になりました。
ただ今回いちばん時間を食ったのは、コードを書くところではありませんでした。OSの都合と、それをどう回避するかの判断です。そこは AI に丸投げできませんでした。エラーメッセージが嘘をつく(「Info.plistに書け」と言われて、書いてあるのに落ちる)場面もありました。
そして最後にヒヤリとしたのは、翻訳が意味を反転させたことでした。動くかどうかより、間違ったまま動くほうが怖い。
使ってみて、はっきりしたことがあります。これは通訳の代わりにはなりません。相手の言葉には、機械が拾えない温度があります。言い淀みや、言い換えたときの間。そこに一番大事なことが入っていたりする。
結局これは、記録を残して、あとから自分の理解を確かめるための道具でした。走るのは自分で、外れかけたときに支えてくれるもの。最初に補助輪と書いたのは、そういう意味です。
同じようなものを作ろうとしている方の、時間の節約になれば嬉しいです。特に壁⑤(ビルドすると許可が消える)は、知らないと確実に溶かします。