feat: preserve stereo channels and require reviewed speaker roles

This commit is contained in:
2026-10-08 16:15:41 +08:00
parent 13948f5982
commit a20d3565af
28 changed files with 1134 additions and 113 deletions
@@ -33,29 +33,43 @@ final class FollowupAudioPipeline
}
self::beat($heartbeat, []);
FollowupAudioPolicy::strictRecordedAt((string) ($task['recorded_at'] ?? ''));
$chunkMs = (int) $this->provider['chunk_seconds'] * 1000;
FollowupAudioPipelineMedia::assertChunkPlan((float) ($task['duration_seconds'] ?? 0), $chunkMs);
$media = new FollowupAudioPipelineMedia($this->settings);
$audio = $media->inspect($path, (string) ($task['sha256'] ?? ''));
if (abs((float) $task['duration_seconds'] - $audio['duration']) > 0.08) { throw new FollowupAudioException('AUDIO_INVALID'); }
FollowupAudioPipelineMedia::assertChunkPlan($audio['duration'], $chunkMs);
$chunkMs = (int) ($audio['channels'] === 2 ? $this->provider['stereo_chunk_seconds'] : $this->provider['chunk_seconds']) * 1000;
FollowupAudioPipelineMedia::assertChunkPlan($audio['duration'], $chunkMs, $audio['channels']);
$state = $checkpoint;
if ($state === []) {
if ((int) ($task['upstream_started_at'] ?? 0) > 0) { throw new FollowupAudioException('RECONCILIATION_REQUIRED', true); }
$next = FollowupAudioPipelineCheckpoint::initial($task, $this->provider['fingerprint'], $this->provider['chunk_seconds'] * 1000);
$next = FollowupAudioPipelineCheckpoint::initial($task, $this->provider['fingerprint'], $chunkMs, $audio['channels']);
$this->save($state, $next, $task, $heartbeat, 'transcribing');
} else { FollowupAudioPipelineCheckpoint::validate($state, $task); }
} else {
FollowupAudioPipelineCheckpoint::validate($state, $task);
if ($state['schema_version'] !== 2 || $state['channel_policy'] !== $this->provider['channel_policy']) {
throw new FollowupAudioException('PIPELINE_POLICY_CHANGED', true);
}
if ($state['source_channels'] !== $audio['channels'] || $state['chunk_ms'] !== $chunkMs) { throw new FollowupAudioException('AUDIO_INVALID', true); }
}
if (FollowupAudioPipelineCheckpoint::hasIntent($state)) { throw new FollowupAudioException('RECONCILIATION_REQUIRED', true); }
$directory = sys_get_temp_dir() . '/followup-asr-' . bin2hex(random_bytes(16));
if (!mkdir($directory, 0700)) { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
$chunkPath = $directory . '/chunk.wav';
try {
foreach ($state['chunks'] as $index => $chunk) {
if ($chunk['state'] === 'complete') { continue; }
if (in_array($chunk['state'], ['complete', 'local_silence'], true)) { continue; }
self::beat($heartbeat, []);
$this->assertSource($audio['path'], $task['sha256']);
$media->chunk($audio, $chunk, $chunkPath, $heartbeat);
$this->assertSource($audio['path'], $task['sha256']);
$silence = FollowupAudioPipelineMedia::digitalSilence($chunkPath);
if ($silence !== null) {
$next = $state;
$next['chunks'][$index] += $silence + ['text' => '', 'source' => 'local_silence'];
$next['chunks'][$index]['state'] = 'local_silence';
$this->save($state, $next, $task, $heartbeat, 'transcribing');
unlink($chunkPath);
continue;
}
$next = $state;
$next['chunks'][$index]['state'] = 'intent';
$next['chunks'][$index]['request_id'] = 'fa-' . bin2hex(random_bytes(16));
@@ -106,8 +120,8 @@ final class FollowupAudioPipeline
throw new FollowupAudioException('UPSTREAM_SCHEMA_INVALID');
}
$result = self::validateAnswer($state['extraction']['answer'], $transcript, $segments, $task['recorded_at']);
$result['uncertainties'][] = ($audio['channels'] > 1 ? '多声道已合并为单声道转写;' : '')
. '未进行说话人分离,患者、家属和客服的归属须人工回听核对。';
$result['uncertainties'][] = ($audio['channels'] === 2 ? '左右声道已独立转写;同一时间窗可能交叠,不代表话轮或角色已识别。' : '单声道转写未进行说话人分离。')
. '患者、家属和客服归属须人工回听核对。';
return $result;
} finally { if (is_file($chunkPath)) { unlink($chunkPath); } rmdir($directory); }
}
@@ -235,6 +249,7 @@ final class FollowupAudioPipeline
|| !str_contains($byId[$evidence['segment_id']]['text'], $evidence['text'])) { throw new FollowupAudioException('UPSTREAM_SCHEMA_INVALID'); }
$segment = $byId[$evidence['segment_id']];
$evidence += ['start_ms' => $segment['start_ms'], 'end_ms' => $segment['end_ms'], 'position_type' => 'segment'];
if (array_key_exists('channel', $segment)) { $evidence['channel'] = $segment['channel']; }
}
unset($evidence);
}