はじめに
音声文字起こしでよく欲しくなるのが、次のような出力です。
Speaker 0: 今日は議事録の確認をします。
Speaker 1: はい、まずスケジュールから確認しましょう。
Speaker 0: 次回までの作業も整理したいです。
Whisper は文字起こしの精度が高く、ローカルでも whisper.cpp を使えば扱いやすいです。
ただし、Whisper の主な役割は「何を話したか」をテキスト化することです。
「誰がいつ話したか」を分けるには、別途、話者分離の時間軸が必要になります。
この記事では、Whisper の文字起こし結果に、別の話者分離タイムラインを重ねて話者ラベルを付ける方法 を説明します。
ここでいう話者識別は、人物の実名を当てる認証ではありません。
音声内の発話区間を Speaker 0、Speaker 1 のように分ける、いわゆる speaker diarization です。
実装例は TypeScript 風に書きますが、考え方は Python や他の言語でも同じです。
必要なのは、Whisper のセグメント時刻と、話者分離側のタイムラインを同じ時間単位で扱うことです。
まず結論
この実装でやっていることは、かなりシンプルです。
- 音声ファイルを
ffmpegで 16kHz / mono / 16-bit PCM WAV に変換する - 同じ WAV を Whisper とローカル話者分離に渡す
- Whisper から「文字列 + 開始時刻 + 終了時刻」を取る
- 話者分離から「開始時刻 + 終了時刻 + 話者番号」を取る
- Whisper の各セグメントに、時間が一番重なった話者を割り当てる
- 隣接する同一話者のセグメントをまとめる
- 最終的に
Speaker N: transcriptの形へ整形する
図にすると、こうです。
音声ファイル
|
v
ffmpeg
|
v
input.wav 16kHz / mono / pcm_s16le
|
+------------------------+
| |
v v
whisper.cpp ローカル話者分離
| |
| text + offsets | start + end + speaker
v v
Whisper segments Speaker timeline
| |
+-----------+------------+
|
v
時間重なりで突合
|
v
[{ speakerLabel, transcript }]
ポイントは、テキストと話者を直接結びつけようとしないことです。
テキストの内容で推測するのではなく、両方の結果を 時間軸 で結合します。
この方が実装が単純で、テストもしやすく、Whisper 側の認識品質をそのまま活かせます。
話者識別に必要な技術要素
Whisper と組み合わせて話者識別を実装するには、主に次の要素が必要です。
| 要素 | 役割 |
|---|---|
| 音声正規化 | 入力音声を 16kHz / mono / PCM WAV などにそろえる |
| ASR | Whisper でテキストとセグメント時刻を取得する |
| Speaker segmentation | 音声中の「話者が変わる区間」を検出する |
| Speaker embedding | 各区間の声の特徴量をベクトル化する |
| Clustering | embedding を話者ごとにまとめる |
| Alignment | Whisper セグメントと話者タイムラインを時間重なりで突合する |
| Post-processing | 隣接する同一話者を結合し、読みやすい出力にする |
Whisper の実行は whisper.cpp の CLI を子プロセスとして呼び出します。
話者分離は、最終的に次のようなタイムラインを返せれば十分です。
export type SpeakerTimelineSegment = {
start: number; // 秒
end: number; // 秒
speaker: number; // 0, 1, 2...
};
この SpeakerTimelineSegment[] を Whisper の JSON 出力に後から合わせます。
最小データ構造
まず、話者識別の統合に必要なデータ構造を小さく定義します。
リクエスト側では、話者識別を使うかどうかと、分かっている場合の最大話者数を渡します。
export type TranscribeOptions = {
speakerLabel: boolean;
maxSpeakers?: number;
};
出力側では、各セグメントに speakerLabel を optional で持たせます。
export type TranscriptSegment = {
speakerLabel?: string;
transcript: string;
};
話者分離が使えない場合や失敗した場合でも、speakerLabel なしの文字起こし結果として返せます。
この optional 設計はかなり重要です。
話者識別は便利ですが、文字起こし本体より壊れやすい処理です。
モデルファイルがない、ネイティブモジュールが読み込めない、音声品質が悪い、話者が分離できない、ということがあります。
その場合でも、Whisper の文字起こしまで失敗にしないために、speakerLabel?: string にしています。
処理の入口
処理の入口は、音声ファイルとオプションを受け取り、文字起こし結果を返す関数にしておくと扱いやすいです。
export async function transcribeWithSpeakerLabels(
inputPath: string,
options: TranscribeOptions,
): Promise<TranscriptSegment[]> {
const wavPath = await convertToWav(inputPath);
const rawWhisperJson = await runWhisper(wavPath);
const speakerTimeline = options.speakerLabel
? await diarize(wavPath, { maxSpeakers: options.maxSpeakers })
: undefined;
return parseWhisperJson(rawWhisperJson, speakerTimeline).transcripts;
}
ここで重要なのは、処理を 3 つに分けることです。
-
runWhisper()は Whisper の JSON を返す -
diarize()は話者タイムラインを返す -
parseWhisperJson()は両方を時間軸で合わせる
この分け方にすると、Whisper だけを差し替えたり、話者分離だけを別実装に変えたりしやすくなります。
また、話者分離が optional なので、speakerLabel=false の場合は Whisper だけで動かせます。
音声を同じ時間基準にそろえる
Whisper と話者分離の結果を時間で合わせるには、両方が同じ音声を見ている必要があります。
そのため、アップロードされた音声は先に ffmpeg で WAV に変換しています。
private async convertToWav(inputPath: string, wavPath: string): Promise<void> {
const ffmpegBin = this.config.ffmpegBin ?? 'ffmpeg';
const result = await this.commandRunner(ffmpegBin, [
'-y',
'-i',
inputPath,
'-ar',
'16000',
'-ac',
'1',
'-c:a',
'pcm_s16le',
wavPath,
]);
if (result.exitCode !== 0) {
throw new Error(`ffmpeg による音声変換に失敗しました。\n${result.stderr || result.stdout}`);
}
}
-ar 16000 で 16kHz、-ac 1 で mono、pcm_s16le で 16-bit PCM にしています。
この input.wav を Whisper と話者分離の両方に渡します。
ここを別々の音声にすると、時間軸が微妙にずれて、後段のマッチングが不安定になります。
Whisper を実行する
Whisper 側は whisper.cpp の CLI を使っています。
Whisper を CLI で実行する場合は、JSON 出力を有効にします。
private async runWhisperCandidate(
candidate: WhisperCandidate,
modelPath: string,
wavPath: string,
jobDir: string,
): Promise<string> {
const outputBase = path.join(jobDir, `output-${candidate.backend}`);
const args = [
'-m',
modelPath,
'-f',
wavPath,
'-oj',
'-of',
outputBase,
'-np',
'-l',
'auto',
];
if (candidate.backend === 'cpu') {
args.push('--no-gpu');
}
const result = await this.commandRunner(candidate.bin, args);
if (result.exitCode !== 0) {
throw new Error(
`${candidate.backend.toUpperCase()} backend failed.\n${result.stderr || result.stdout}`,
);
}
const jsonPath = `${outputBase}.json`;
return fs.readFile(jsonPath, 'utf-8');
}
重要なのは -oj です。
これにより、Whisper の結果を JSON で受け取れます。
この JSON には、実装上 transcription[].offsets または segments[].start/end のような時間情報が含まれます。
後で話者時間軸と合わせるため、この時間情報が必要です。
話者分離は Whisper と並行実行する
Whisper の文字起こしと話者分離は、同じ WAV に対して実行できます。
そのため、実装では話者分離を先に Promise として開始し、Whisper の完了を待ってから突合します。
Whisper 実行と話者分離を並行させるコード
以下は、本文に関係する部分だけに絞った実装例です。
private async runWhisper(
wavPath: string,
jobDir: string,
options: DiarizationOptions = {},
): Promise<GetTranscriptionResponse> {
const timelinePromise = this.runWhisperDiarization(wavPath, options);
const raw = await this.runWhisperRaw(wavPath, jobDir);
const timeline = await timelinePromise;
return parseWhisperJson(raw, timeline);
}
この形にしている理由は 2 つあります。
1 つ目は、待ち時間を減らすためです。
Whisper が終わってから話者分離を始めると、単純に処理時間が足し算になります。
2 つ目は、責務を分けるためです。
runWhisperRaw() は Whisper の JSON を返すだけです。
runWhisperDiarization() は話者タイムラインを返すだけです。
parseWhisperJson() が両者を合わせます。
この分け方にすると、それぞれをテストしやすくなります。
話者分離が失敗しても Whisper は返す
話者分離は optional です。
そのため、失敗したら undefined を返し、Whisper の文字起こしだけで続行します。
話者分離の fallback 処理
本文では、実装の本質が分かるようにアダプタ名を一般化しています。
実際のコードも同じ方針で、利用不可・未実装・失敗時に undefined を返します。
private async runWhisperDiarization(
wavPath: string,
options: DiarizationOptions,
): Promise<SpeakerTimelineSegment[] | undefined> {
if (options.speakerLabel !== true) {
return undefined;
}
const engine = this.localDiarizationEngine;
if (typeof engine.diarizeFile !== 'function') {
return undefined;
}
try {
if (!(await engine.isAvailable())) {
return undefined;
}
const timeline = await engine.diarizeFile(wavPath, {
maxSpeakers: options.maxSpeakers,
});
return timeline.length > 0 ? timeline : undefined;
} catch (error) {
console.warn(
`[transcribe] whisper の話者分離に失敗したため、話者ラベルなしで続行します: ${
error instanceof Error ? error.message : String(error)
}`,
);
return undefined;
}
}
ここで大事なのは、話者分離の失敗を文字起こし全体の失敗にしないことです。
ユーザーから見ると、話者ラベル付きの結果が理想です。
しかし、話者ラベルが取れない場合でも、文字起こし本文が返る方が実用的です。
そのため、parseWhisperJson(raw, timeline) の timeline は optional になっています。
話者分離モジュールの中身
話者分離側は、Whisper と同じ WAV を読み、SpeakerTimelineSegment[] を返します。
例えば sherpa-onnx 系の speaker diarization では、次の 2 種類の ONNX モデルで話者タイムラインを作れます。
| モデル | 用途 |
|---|---|
pyannote-segmentation.onnx |
話者が変わる区間を見つける |
speaker-embedding-campplus-zh-en.onnx |
各区間の話者埋め込みを作る |
実装上は、話者分離だけを実行するとき、この 2 つだけを確保します。
const DIARIZATION_MODEL_FILES = [
'pyannote-segmentation.onnx',
'speaker-embedding-campplus-zh-en.onnx',
];
話者数が指定されている場合は固定クラスタ数、指定されていない場合はしきい値クラスタリングにしています。
const numClusters = maxSpeakers > 0 ? maxSpeakers : -1;
const diarizer = new sherpa.OfflineSpeakerDiarization({
segmentation: {
pyannote: { model: this.modelPath('pyannote-segmentation.onnx') },
numThreads: this.numThreads,
},
embedding: {
model: this.modelPath('speaker-embedding-campplus-zh-en.onnx'),
numThreads: this.numThreads,
},
clustering: { numClusters, threshold: this.speakerThreshold },
minDurationOn: 0.2,
minDurationOff: 0.5,
});
maxSpeakers を指定すると numClusters にその値を渡します。
例えば会議参加者が 2 人だと分かっているなら、maxSpeakers=2 にするとクラスタリングが安定しやすくなります。
人数が分からない場合は -1 にして、しきい値ベースで分けます。
Whisper セグメントの時間範囲を取り出す
Whisper の JSON 形式は、実行方法や互換形式によって少し違います。
この実装では、まず offsets.from/to を優先し、なければ start/end を見ます。
const whisperSegmentRange = (
segment: WhisperJsonSegment,
): { start: number; end: number } | undefined => {
const offsets = segment.offsets;
if (
isRecord(offsets) &&
typeof offsets.from === 'number' &&
typeof offsets.to === 'number' &&
offsets.to >= offsets.from
) {
return { start: offsets.from / 1000, end: offsets.to / 1000 };
}
if (
typeof segment.start === 'number' &&
typeof segment.end === 'number' &&
segment.end >= segment.start
) {
return { start: segment.start, end: segment.end };
}
return undefined;
};
offsets はミリ秒なので、秒に変換しています。
話者タイムラインは秒単位です。
単位をそろえないと、重なり計算が壊れます。
また、end >= start の確認も入れています。
音声処理では、壊れた入力や互換 JSON で変な値が来る可能性があります。
ここで最低限の正規化をしておくと、後段のロジックを単純にできます。
コア実装: 時間重なりで話者を選ぶ
一番大事なのはここです。
Whisper の 1 セグメントに対して、話者タイムラインの中で一番時間が重なっている話者を選びます。
export const speakerForRange = (
range: { start: number; end: number },
timeline: SpeakerTimelineSegment[],
): number | undefined => {
let best: { speaker: number; overlap: number } | undefined;
for (const segment of timeline) {
const overlap = Math.min(range.end, segment.end) - Math.max(range.start, segment.start);
if (overlap > 0 && (!best || overlap > best.overlap)) {
best = { speaker: segment.speaker, overlap };
}
}
if (best) {
return best.speaker;
}
if (timeline.length === 0) {
return undefined;
}
let closest: { speaker: number; distance: number } | undefined;
for (const segment of timeline) {
let distance = 0;
if (range.start >= segment.end) {
distance = range.start - segment.end;
} else if (segment.start >= range.end) {
distance = segment.start - range.end;
}
if (!closest || distance < closest.distance) {
closest = { speaker: segment.speaker, distance };
}
}
return closest?.speaker;
};
考え方はこうです。
Whisper segment:
1.0s ---------------- 2.0s
Speaker timeline:
Speaker 0: 0.0s ---------------- 1.9s
Speaker 1: 1.9s -------- 3.0s
overlap:
Speaker 0: 0.9s
Speaker 1: 0.1s
=> Speaker 0
単純に「開始時刻が近い話者」を選ばないのがポイントです。
Whisper のセグメントは、話者の切り替わりと完全に一致するとは限りません。
1 つの Whisper セグメントが話者切り替わりを少しまたぐことがあります。
その場合、開始時刻だけを見ると誤りやすいです。
区間の重なりを見る方が自然です。
ただし、短い無音や VAD の境界で、どの話者区間とも重ならないこともあります。
その場合は、最も近い話者を fallback として返します。
タイムラインが空のときだけ undefined にしています。
この fallback により、数百ミリ秒の隙間で speakerLabel が突然消えるのを避けられます。
Whisper JSON に話者ラベルを付ける
次に、Whisper の JSON をアプリのレスポンス形式に変換します。
Whisper JSON を `Transcript[]` に変換するコード
以下は parseWhisperJson から、話者識別に関係する部分を中心に抜き出した実装例です。
const parseWhisperJson = (
raw: string,
speakerTimeline?: SpeakerTimelineSegment[],
): GetTranscriptionResponse => {
const output = JSON.parse(raw) as WhisperJsonOutput;
const detectedLanguage =
(typeof output.result?.language === 'string' && output.result.language) ||
(typeof output.results?.language_code === 'string' && output.results.language_code) ||
(typeof output.language === 'string' && output.language) ||
'';
const languageCode = normalizeLanguageCode(detectedLanguage);
const rawSegments =
(Array.isArray(output.transcription) && output.transcription) ||
(Array.isArray(output.segments) && output.segments) ||
(Array.isArray(output.results?.audio_segments) && output.results.audio_segments) ||
[];
const transcripts: Transcript[] = rawSegments
.map<Transcript>((segment) => {
const text =
(typeof segment.text === 'string' && segment.text) ||
(typeof segment.transcript === 'string' && segment.transcript) ||
'';
let speaker =
(typeof segment.speaker === 'string' && segment.speaker) ||
(typeof segment.speaker_label === 'string' && segment.speaker_label) ||
undefined;
if (!speaker && speakerTimeline && speakerTimeline.length > 0) {
const range = whisperSegmentRange(segment);
const matched = range ? speakerForRange(range, speakerTimeline) : undefined;
if (matched !== undefined) {
speaker = `Speaker ${matched}`;
}
}
return speaker
? { speakerLabel: speaker, transcript: text.trim() }
: { transcript: text.trim() };
})
.filter((segment) => segment.transcript);
if (transcripts.length === 0 && typeof output.text === 'string' && output.text.trim()) {
transcripts.push({
transcript: normalizeTranscriptText(output.text, languageCode),
});
}
return {
status: 'COMPLETED',
languageCode,
transcripts: mergeAdjacentSpeakerSegments(transcripts).map((segment) => ({
...segment,
transcript: normalizeTranscriptText(segment.transcript, languageCode),
})),
};
};
ここでやっていることは 4 つです。
- Whisper JSON の言語コードを読む
-
transcription/segments/results.audio_segmentsのどれかからセグメントを読む - セグメントに話者情報がなければ、話者タイムラインとの時間重なりで補う
- 最後に隣接する同一話者のセグメントを結合する
Whisper JSON の形が複数あるため、入口では少し幅を持たせています。
ただし、内部の処理形式は TranscriptSegment[] のような単純な構造にそろえます。
この「外部形式は柔軟に受け、内部形式は固定する」方針にすると、後段の表示、保存、整形、テストが楽になります。
隣接する同一話者を結合する
話者ラベルを付けた後、そのまま返すとセグメントが細かくなりすぎることがあります。
例えば、
Speaker 0: こんにちは 世界
Speaker 0: ありがとう
Speaker 1: さようなら
よりも、
Speaker 0: こんにちは世界ありがとう
Speaker 1: さようなら
の方が読みやすいです。
そのため、隣接する同一話者のセグメントは結合します。
export const mergeAdjacentSpeakerSegments = (segments: TranscriptSegment[]): TranscriptSegment[] =>
segments.reduce((prev, item) => {
if (prev.length === 0 || prev[prev.length - 1].speakerLabel !== item.speakerLabel) {
prev.push({ ...item });
return prev;
}
prev[prev.length - 1].transcript += ` ${item.transcript}`;
return prev;
}, [] as Transcript[]);
さらに、日本語では Whisper 出力に入る空白が読みにくいことがあります。
そこで、言語コードが日本語の場合は空白を除去しています。
const normalizeTranscriptText = (text: string, languageCode: string): string => {
const normalized = text.trim();
return languageCode.startsWith('ja') ? normalized.replace(/ /g, '') : normalized;
};
この 2 つにより、最終的な文字起こし結果がかなり読みやすくなります。
実行可否を判定する
話者識別は、必要な実行モジュールやモデルがある場合だけ使えます。
そのため、本格的に組み込む場合は、処理開始前に「話者分離が使えるか」を判定する関数を用意しておくと便利です。
Whisper の話者識別対応は固定値ではなく、ローカル話者分離が使えるかどうかで動的に決めます。
private async detectWhisperDiarizationSupport(): Promise<boolean> {
if (typeof this.localDiarizationEngine.diarizeFile !== 'function') {
return false;
}
return this.localDiarizationEngine.isAvailable().catch(() => false);
}
この関数が false の場合は、話者分離を実行せず、Whisper の文字起こしだけを返します。
この設計にしておくと、次のような状態でも処理全体を壊さずに済みます。
- モデルがまだない
- ネイティブモジュールが読み込めない
- 対象 OS で未対応
話者識別が使えないときは、speakerLabel なしの Whisper 結果に fallback します。
出力形式
最終的には、speakerLabel があれば話者名を前置し、なければ本文だけを出します。
const formatTranscript = (segments: TranscriptSegment[]): string =>
segments
.map((segment) =>
segment.speakerLabel
? `${segment.speakerLabel}: ${segment.transcript}`
: segment.transcript,
)
.join('\n');
出力例はこうなります。
Speaker 0: こんにちは世界ありがとう
Speaker 1: さようなら
ここで注意したいのは、Speaker 0 は人物の実名ではないことです。
話者分離で分かるのは「同じ声のクラスタ」です。
それが誰なのかは、別の本人確認やユーザー入力がない限り分かりません。
つまり、このレイヤーの責務は Speaker 0、Speaker 1 のように区間を分けるところまでです。
テストで守っていること
話者識別は、見た目だけで確認すると壊れやすいです。
テストでは、時間重なりの contract を守ります。
最大重なりで話者を割り当てるテスト
const whisperSegments = [
{ text: 'こんにちは 世界', from: 0, to: 1000 },
{ text: 'ありがとう', from: 1000, to: 2000 },
{ text: 'さようなら', from: 2100, to: 3000 },
];
const engine = {
isAvailable: async () => true,
transcribe: async () => ({}),
diarizeFile: async (_wavPath, options) => {
diarizeCalls.push({ maxSpeakers: options?.maxSpeakers });
return [
{ start: 0, end: 1.9, speaker: 0 },
{ start: 1.9, end: 3, speaker: 1 },
];
},
};
const result = await runWhisperJob({
engine,
runner: createWhisperRunner(whisperSegments),
speakerLabel: true,
maxSpeakers: 2,
});
expect(result.transcripts).toEqual([
{ speakerLabel: 'Speaker 0', transcript: 'こんにちは世界ありがとう' },
{ speakerLabel: 'Speaker 1', transcript: 'さようなら' },
]);
expect(diarizeCalls[0].maxSpeakers).toBe(2);
このテストで見ているのは、主に次の 3 点です。
- Whisper の 2 つ目のセグメントが
Speaker 0と 0.9 秒、Speaker 1と 0.1 秒重なるのでSpeaker 0になる - 隣接する
Speaker 0のセグメントが結合される -
maxSpeakersが話者分離に引き継がれる
さらに、speakerForRange 単体でもテストしています。
const timeline: SpeakerTimelineSegment[] = [
{ start: 0, end: 2, speaker: 0 },
{ start: 2, end: 4, speaker: 1 },
];
expect(speakerForRange({ start: 0.5, end: 1.5 }, timeline)).toBe(0);
expect(speakerForRange({ start: 1.5, end: 3.5 }, timeline)).toBe(1);
expect(speakerForRange({ start: 1, end: 3 }, timeline)).toBe(0);
expect(speakerForRange({ start: 5, end: 6 }, timeline)).toBe(1);
expect(speakerForRange({ start: 0, end: 1 }, [])).toBeUndefined();
同じ重なりの場合は、先に現れた話者を選びます。
重なりがない場合は、最も近い話者を選びます。
タイムラインが空の場合だけ、話者なしにします。
このあたりは小さいロジックですが、実際の体験にかなり効きます。
なぜこの設計にしたのか
Whisper の強みを壊さない
Whisper は文字起こしに強いです。
そのため、話者識別を入れるために、文字起こし本文を別の処理に置き換える必要はありません。
Whisper が出したテキストをそのまま使い、話者ラベルだけを後付けします。
この方が責務が明確です。
時間軸は安定した結合キーになる
テキストの内容から話者を推測するのは難しいです。
例えば「はい」「そうですね」「お願いします」のような短い発話では、内容だけで話者を判断できません。
一方、Whisper にも話者分離にも時間情報があります。
だから、結合キーとして時間を使います。
失敗時に結果を捨てない
話者識別は、音声品質や環境に影響されます。
話者分離が失敗しても、Whisper の結果まで失敗にするとユーザー体験が悪くなります。
そのため、話者分離は optional にして、失敗時はラベルなし文字起こしとして返します。
利用可否判定を入れる
話者識別が使えるかどうかは、呼び出し側の設定値だけでは判断できません。
必要な実行モジュールやモデルの有無を見て supportsSpeakerDiarization を返します。
呼び出し側はそれに従って、話者分離を有効にするかどうかを決めます。
テストしやすい
この設計では、最重要ロジックが speakerForRange() に切り出されています。
音声ファイルを使わなくても、
speakerForRange({ start: 1.5, end: 3.5 }, timeline)
のように純粋関数としてテストできます。
音声処理全体を毎回回さなくても、話者割当の contract を守れます。
実装するときの注意点
1. Whisper の JSON に時間情報を出す
話者識別と統合するなら、Whisper の出力には必ずセグメント時間が必要です。
例えば whisper.cpp では -oj を渡し、JSON を読み込みます。
2. Whisper と話者分離に同じ WAV を渡す
時間軸を合わせるため、両方に同じ input.wav を渡します。
入力音声を別々に変換すると、サンプルレートや無音処理の差で時間がずれます。
3. 単位をそろえる
Whisper の offsets はミリ秒、話者タイムラインは秒、という形になりがちです。
この実装では、offsets.from / 1000 のように秒へ変換してから比較しています。
4. 話者分離は optional にする
本番向けには、話者分離が失敗しても文字起こしは返す設計にした方が扱いやすいです。
speakerLabel?: string のように optional にしておくと、後段の処理も自然に fallback できます。
5. 隣接セグメントを結合する
Whisper のセグメントは細かく分かれます。
話者ラベルを付けたあと、同じ話者が続くなら結合した方が読みやすくなります。
6. 最大話者数を指定できるようにする
会議の参加者数が分かっている場合、最大話者数を指定できるとクラスタリングが安定しやすいです。
例えば 1〜10 の整数だけを受け付けるなら、次のように検証できます。
export const speakerNumSchema = z
.number()
.int({ message: '話者の最大数は整数で設定してください' })
.min(1, { message: '話者の最大数は1以上に設定してください' })
.max(10, { message: '話者の最大数は10以下に設定してください' });
この記事の要点を 3 行でまとめると
- Whisper の文字起こし結果に話者ラベルを付けるには、Whisper セグメントと話者タイムラインを時間重なりで突合すればよいです。
- 実装の中心は
runWhisper()、parseWhisperJson()、speakerForRange()の 3 つで、Whisper 本文はそのまま使い、Speaker Nだけを後付けします。 - 話者分離は optional にし、失敗時はラベルなしの Whisper 結果を返すことで、実用上壊れにくい文字起こし機能になります。
まとめ
Whisper と話者識別を統合するときに、難しく考えすぎる必要はありません。
Whisper は「何を話したか」を出す。
話者分離は「誰がいつ話したか」を出す。
この 2 つを時間軸で合わせれば、Speaker 0: ... のような実用的な結果を作れます。
この記事では、Whisper の JSON セグメント、話者分離タイムライン、speakerForRange() による最大重なり判定を組み合わせる方法を説明しました。
実装としては小さめですが、音声変換、Whisper 実行、話者分離、時間軸 alignment、fallback、テストまでつなげると、かなり使える形になります。
同じような機能を作る場合は、まず SpeakerTimelineSegment[] を作り、Whisper セグメントに対して最大重なりで Speaker N を付けるところから始めるのがよいと思います。