mirror of
https://github.com/FluidInference/FluidAudio.git
synced 2026-06-11 20:24:36 +00:00
## Summary - Extract shared fixture helpers into `DiarizationTestFixtures` enum, removing ~200 lines of duplicate code across `LSEENDIntegrationTests` and `SpeakerEnrollmentTests` - Replace fragile `Mirror`-based private state inspection with `internal` `hasActiveSession` property on `LSEENDDiarizerAPI` - Fix non-deterministic `srand48` seed in `SortformerTests` (use constant `42` instead of time-based seed) - Fix asymmetric skip guards in Sortformer enrollment tests (`XCTSkipIf` instead of `XCTAssertNotNil` for host-dependent segments) ## Test plan - [x] `swift build --build-tests` passes - [ ] `swift test --filter SortformerTests` passes - [ ] `swift test --filter LSEENDIntegrationTests` passes - [ ] `swift test --filter SpeakerEnrollmentTests` passes <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/fluidinference/fluidaudio/pull/395" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
265 lines
10 KiB
Swift
265 lines
10 KiB
Swift
#if os(macOS)
|
|
import FluidAudio
|
|
import Foundation
|
|
|
|
/// Handler for the 'lseend' command - LS-EEND streaming diarization
|
|
enum LSEENDCommand {
|
|
private static let logger = AppLogger(category: "LSEEND")
|
|
|
|
static func run(arguments: [String]) async {
|
|
guard !arguments.isEmpty else {
|
|
fputs("ERROR: No audio file specified\n", stderr)
|
|
fflush(stderr)
|
|
logger.error("No audio file specified")
|
|
printUsage()
|
|
exit(1)
|
|
}
|
|
|
|
let audioFile = arguments[0]
|
|
var outputFile: String?
|
|
var variant: LSEENDVariant = .dihard3
|
|
var threshold: Float = 0.5
|
|
|
|
// Post-processing parameters
|
|
var onset: Float?
|
|
var offset: Float?
|
|
var padOnset: Float?
|
|
var padOffset: Float?
|
|
var minDurationOn: Float?
|
|
var minDurationOff: Float?
|
|
|
|
// Parse remaining arguments
|
|
var i = 1
|
|
while i < arguments.count {
|
|
switch arguments[i] {
|
|
case "--output":
|
|
if i + 1 < arguments.count {
|
|
outputFile = arguments[i + 1]
|
|
i += 1
|
|
}
|
|
case "--variant":
|
|
if i + 1 < arguments.count {
|
|
let v = arguments[i + 1].lowercased()
|
|
switch v {
|
|
case "ami":
|
|
variant = .ami
|
|
case "callhome":
|
|
variant = .callhome
|
|
case "dihard2":
|
|
variant = .dihard2
|
|
case "dihard3":
|
|
variant = .dihard3
|
|
default:
|
|
logger.warning("Unknown variant: \(arguments[i + 1]), using dihard3")
|
|
}
|
|
i += 1
|
|
}
|
|
case "--threshold":
|
|
if i + 1 < arguments.count, let v = Float(arguments[i + 1]) {
|
|
threshold = v
|
|
i += 1
|
|
}
|
|
case "--onset":
|
|
if i + 1 < arguments.count, let v = Float(arguments[i + 1]) {
|
|
onset = v
|
|
i += 1
|
|
}
|
|
case "--offset":
|
|
if i + 1 < arguments.count, let v = Float(arguments[i + 1]) {
|
|
offset = v
|
|
i += 1
|
|
}
|
|
case "--pad-onset":
|
|
if i + 1 < arguments.count, let v = Float(arguments[i + 1]) {
|
|
padOnset = v
|
|
i += 1
|
|
}
|
|
case "--pad-offset":
|
|
if i + 1 < arguments.count, let v = Float(arguments[i + 1]) {
|
|
padOffset = v
|
|
i += 1
|
|
}
|
|
case "--min-duration-on":
|
|
if i + 1 < arguments.count, let v = Float(arguments[i + 1]) {
|
|
minDurationOn = v
|
|
i += 1
|
|
}
|
|
case "--min-duration-off":
|
|
if i + 1 < arguments.count, let v = Float(arguments[i + 1]) {
|
|
minDurationOff = v
|
|
i += 1
|
|
}
|
|
case "--help":
|
|
printUsage()
|
|
return
|
|
default:
|
|
logger.warning("Unknown option: \(arguments[i])")
|
|
}
|
|
i += 1
|
|
}
|
|
|
|
print("LS-EEND Diarization")
|
|
print(" Audio: \(audioFile)")
|
|
print(" Variant: \(variant.rawValue)")
|
|
print(" Threshold: \(threshold)")
|
|
|
|
var timelineConfig = DiarizerTimelineConfig(onsetThreshold: threshold, onsetPadFrames: 0)
|
|
if let v = onset { timelineConfig.onsetThreshold = v }
|
|
if let v = offset { timelineConfig.offsetThreshold = v }
|
|
if let v = padOnset { timelineConfig.onsetPadSeconds = v }
|
|
if let v = padOffset { timelineConfig.offsetPadSeconds = v }
|
|
if let v = minDurationOn { timelineConfig.minDurationOn = v }
|
|
if let v = minDurationOff { timelineConfig.minDurationOff = v }
|
|
|
|
let diarizer = LSEENDDiarizer(computeUnits: .cpuOnly, timelineConfig: timelineConfig)
|
|
|
|
do {
|
|
let loadStart = Date()
|
|
print("Loading models from HuggingFace...")
|
|
try await diarizer.initialize(variant: variant)
|
|
let loadTime = Date().timeIntervalSince(loadStart)
|
|
print("Models loaded in \(String(format: "%.2f", loadTime))s")
|
|
|
|
guard let sampleRate = diarizer.targetSampleRate,
|
|
let frameHz = diarizer.modelFrameHz,
|
|
let numSpeakers = diarizer.numSpeakers
|
|
else {
|
|
print("ERROR: Failed to read model parameters after initialization")
|
|
exit(1)
|
|
}
|
|
print(" Sample rate: \(sampleRate) Hz")
|
|
print(" Frame rate: \(String(format: "%.1f", frameHz)) Hz")
|
|
print(" Speakers: \(numSpeakers)")
|
|
} catch {
|
|
print("ERROR: Failed to initialize LS-EEND: \(error)")
|
|
exit(1)
|
|
}
|
|
|
|
do {
|
|
print("Processing...")
|
|
fflush(stdout)
|
|
let startTime = Date()
|
|
let audioURL = URL(fileURLWithPath: audioFile)
|
|
let timeline = try diarizer.processComplete(audioFileURL: audioURL)
|
|
|
|
let processingTime = Date().timeIntervalSince(startTime)
|
|
let duration = timeline.finalizedDuration
|
|
let rtfx = duration / Float(processingTime)
|
|
|
|
print("Processing completed in \(String(format: "%.2f", processingTime))s")
|
|
print(" Duration: \(String(format: "%.1f", duration))s")
|
|
print(" Real-time factor (RTFx): \(String(format: "%.1f", rtfx))x")
|
|
print(" Total frames: \(timeline.numFinalizedFrames)")
|
|
print(" Frame duration: \(String(format: "%.3f", timeline.config.frameDurationSeconds))s")
|
|
|
|
// Collect all segments across speakers
|
|
var allSegments: [DiarizerSegment] = []
|
|
for (_, speaker) in timeline.speakers {
|
|
allSegments.append(contentsOf: speaker.finalizedSegments)
|
|
}
|
|
allSegments.sort()
|
|
|
|
print(" Found \(allSegments.count) segments")
|
|
|
|
// Print segments
|
|
print("\n--- Speaker Segments ---")
|
|
for segment in allSegments {
|
|
let start = String(format: "%.2f", segment.startTime)
|
|
let end = String(format: "%.2f", segment.endTime)
|
|
let dur = String(format: "%.2f", segment.duration)
|
|
print("\(segment.speakerLabel): \(start)s - \(end)s (\(dur)s)")
|
|
}
|
|
|
|
// Print speaker activity summary
|
|
let numSpeakers = timeline.config.numSpeakers
|
|
print("\n--- Speaker Activity Summary ---")
|
|
let predictions = timeline.finalizedPredictions
|
|
let numFrames = timeline.numFinalizedFrames
|
|
var speakerActivity = [Float](repeating: 0, count: numSpeakers)
|
|
let activityThreshold = timeline.config.onsetThreshold
|
|
for frame in 0..<numFrames {
|
|
for spk in 0..<numSpeakers {
|
|
let idx = frame * numSpeakers + spk
|
|
if idx < predictions.count, predictions[idx] > activityThreshold {
|
|
speakerActivity[spk] += timeline.config.frameDurationSeconds
|
|
}
|
|
}
|
|
}
|
|
for spk in 0..<numSpeakers {
|
|
let activeTime = String(format: "%.1f", speakerActivity[spk])
|
|
let percent = String(format: "%.1f", (speakerActivity[spk] / duration) * 100)
|
|
print("Speaker \(spk): \(activeTime)s active (\(percent)%)")
|
|
}
|
|
|
|
// Save output if requested
|
|
if let outputFile = outputFile {
|
|
var output: [String: Any] = [
|
|
"audioFile": audioFile,
|
|
"variant": variant.rawValue,
|
|
"durationSeconds": duration,
|
|
"processingTimeSeconds": processingTime,
|
|
"rtfx": rtfx,
|
|
"totalFrames": numFrames,
|
|
"frameDurationSeconds": timeline.config.frameDurationSeconds,
|
|
"segmentCount": allSegments.count,
|
|
]
|
|
|
|
var segmentDicts: [[String: Any]] = []
|
|
for segment in allSegments {
|
|
segmentDicts.append([
|
|
"speaker": segment.speakerLabel,
|
|
"speakerIndex": segment.speakerIndex,
|
|
"startTimeSeconds": segment.startTime,
|
|
"endTimeSeconds": segment.endTime,
|
|
"durationSeconds": segment.duration,
|
|
])
|
|
}
|
|
output["segments"] = segmentDicts
|
|
|
|
let jsonData = try JSONSerialization.data(
|
|
withJSONObject: output,
|
|
options: [.prettyPrinted, .sortedKeys]
|
|
)
|
|
try jsonData.write(to: URL(fileURLWithPath: outputFile))
|
|
print("Results saved to: \(outputFile)")
|
|
}
|
|
|
|
} catch {
|
|
print("ERROR: Failed to process audio: \(error)")
|
|
exit(1)
|
|
}
|
|
}
|
|
|
|
private static func printUsage() {
|
|
print(
|
|
"""
|
|
|
|
LS-EEND Command Usage:
|
|
fluidaudio lseend <audio_file> [options]
|
|
|
|
Options:
|
|
--variant <name> Model variant: ami, callhome, dihard2, dihard3 (default: dihard3)
|
|
--threshold <value> Speaker activity threshold (default: 0.5)
|
|
--onset <value> Onset threshold for speech detection (default: 0.5)
|
|
--offset <value> Offset threshold for speech detection (default: 0.5)
|
|
--pad-onset <value> Padding before speech segments in seconds
|
|
--pad-offset <value> Padding after speech segments in seconds
|
|
--min-duration-on <v> Minimum speech segment duration in seconds
|
|
--min-duration-off <v> Minimum silence duration in seconds
|
|
--output <file> Save results to JSON file
|
|
--help Show this help message
|
|
|
|
Examples:
|
|
# Basic usage (downloads model from HuggingFace)
|
|
fluidaudio lseend audio.wav
|
|
|
|
# With specific variant
|
|
fluidaudio lseend audio.wav --variant ami
|
|
|
|
# Save results to file
|
|
fluidaudio lseend audio.wav --output results.json
|
|
""")
|
|
}
|
|
}
|
|
#endif
|