diff --git a/Sources/FluidAudio/ModelNames.swift b/Sources/FluidAudio/ModelNames.swift index edfa91bd..42d82612 100644 --- a/Sources/FluidAudio/ModelNames.swift +++ b/Sources/FluidAudio/ModelNames.swift @@ -317,12 +317,21 @@ public enum Repo: String, CaseIterable, Sendable { /// Encoder precision for the v3 Parakeet TDT 0.6B encoder. public enum ParakeetEncoderPrecision: String, Sendable, CaseIterable { case int8 + /// Opt-in int8 per-channel linear re-quantization of the v3 encoder + /// (`Encoder_v2.mlmodelc`, 568M vs 425M). Avoids token corruption the + /// original 6-bit-LUT palettized `Encoder.mlmodelc` exhibits under + /// specific right-context (issue #760). `.int8` remains the default and + /// keeps loading the original file; select this explicitly to use the + /// rebuild. + case int8V2 = "int8-v2" case int4 public var encoderFileName: String { switch self { case .int8: return ModelNames.ASR.encoderFile + case .int8V2: + return ModelNames.ASR.encoderV2File case .int4: return ModelNames.ASR.encoderInt4File } @@ -391,6 +400,10 @@ public enum ModelNames { /// Joint decoder variant for v3 that exposes top-K outputs /// (`top_k_ids`, `top_k_logits`) used for language-aware script filtering. public static let jointV3File = "JointDecisionv3.mlmodelc" + /// v3 encoder re-quantized as int8 per-channel linear (issue #760). + /// Published alongside the immutable original `Encoder.mlmodelc`; + /// HF repo files are never mutated in place, fixes ship as new names. + public static let encoderV2File = "Encoder_v2.mlmodelc" public static let encoderInt4File = "EncoderInt4.mlmodelc" public static let ctcHeadFile = ctcHead + ".mlmodelc" diff --git a/Sources/FluidAudioCLI/Commands/ASR/Parakeet/SlidingWindow/TranscribeCommand.swift b/Sources/FluidAudioCLI/Commands/ASR/Parakeet/SlidingWindow/TranscribeCommand.swift index 838396da..6e3844bf 100644 --- a/Sources/FluidAudioCLI/Commands/ASR/Parakeet/SlidingWindow/TranscribeCommand.swift +++ b/Sources/FluidAudioCLI/Commands/ASR/Parakeet/SlidingWindow/TranscribeCommand.swift @@ -1023,7 +1023,8 @@ enum TranscribeCommand { --output-json Save full transcription to JSON --model-version ASR model version (default: v3) --model-dir Local model directory (skips download) - --encoder-precision Encoder quantization (default: int8) + --encoder-precision Encoder quantization (default: int8; + int8-v2 = int8-linear rebuild, avoids #760) --language Language hint (e.g., en, de, fr, es) --custom-vocab Apply vocabulary boosting in batch mode --no-mel-context Disable 80ms mel-context prepend for long-form batch ASR diff --git a/Tests/FluidAudioTests/ASR/Parakeet/SlidingWindow/AsrModelsEncoderV2Tests.swift b/Tests/FluidAudioTests/ASR/Parakeet/SlidingWindow/AsrModelsEncoderV2Tests.swift new file mode 100644 index 00000000..71f4e76b --- /dev/null +++ b/Tests/FluidAudioTests/ASR/Parakeet/SlidingWindow/AsrModelsEncoderV2Tests.swift @@ -0,0 +1,59 @@ +import XCTest + +@testable import FluidAudio + +/// Opt-in Encoder_v2 (int8-linear, issue #760) precision selection. +final class AsrModelsEncoderV2Tests: XCTestCase { + + func testEncoderFileNames() { + XCTAssertEqual(ParakeetEncoderPrecision.int8.encoderFileName, "Encoder.mlmodelc") + XCTAssertEqual(ParakeetEncoderPrecision.int8V2.encoderFileName, "Encoder_v2.mlmodelc") + XCTAssertEqual(ParakeetEncoderPrecision.int4.encoderFileName, "EncoderInt4.mlmodelc") + } + + func testVariantStringRoundTrip() { + // The download variant string must map back to the same precision so + // ModelHub's required-model set matches the file AsrModels loads. + for precision in ParakeetEncoderPrecision.allCases { + XCTAssertEqual(ParakeetEncoderPrecision(rawValue: precision.rawValue), precision) + } + } + + func testRequiredModelsV3PerPrecision() { + // int8 stays the default and keeps requiring the original encoder; + // int8-v2 is strictly opt-in. + let int8 = ModelNames.ASR.requiredModelsV3(precision: .int8) + XCTAssertTrue(int8.contains("Encoder.mlmodelc")) + XCTAssertFalse(int8.contains("Encoder_v2.mlmodelc")) + + let int8V2 = ModelNames.ASR.requiredModelsV3(precision: .int8V2) + XCTAssertTrue(int8V2.contains("Encoder_v2.mlmodelc")) + XCTAssertFalse(int8V2.contains("Encoder.mlmodelc")) + } + + func testModelsExistChecksPrecisionSpecificEncoder() throws { + let parentDir = FileManager.default.temporaryDirectory + .appendingPathComponent("AsrModelsEncoderV2Tests-\(UUID().uuidString)") + let repoDir = parentDir.appendingPathComponent(Repo.parakeetV3.folderName) + defer { try? FileManager.default.removeItem(at: parentDir) } + + for file in [ + ModelNames.ASR.preprocessorFile, + ModelNames.ASR.decoderFile, + ModelNames.ASR.jointV3File, + ModelNames.ASR.encoderV2File, + ] { + try FileManager.default.createDirectory( + at: repoDir.appendingPathComponent(file), withIntermediateDirectories: true) + } + try Data("{}".utf8).write( + to: repoDir.appendingPathComponent(ModelNames.ASR.vocabularyFile)) + + // Only Encoder_v2 on disk: satisfies an explicit int8-v2 request but + // NOT the int8 default (which still requires Encoder.mlmodelc). + XCTAssertTrue( + AsrModels.modelsExist(at: repoDir, version: .v3, encoderPrecision: .int8V2)) + XCTAssertFalse( + AsrModels.modelsExist(at: repoDir, version: .v3, encoderPrecision: .int8)) + } +}