Skip to content

feat(asr): add SenseVoice-Small on-device ASR for Cantonese#579

Open
hoishing wants to merge 7 commits into
altic-dev:mainfrom
hoishing:pr/sensevoice-cantonese
Open

feat(asr): add SenseVoice-Small on-device ASR for Cantonese#579
hoishing wants to merge 7 commits into
altic-dev:mainfrom
hoishing:pr/sensevoice-cantonese

Conversation

@hoishing

@hoishing hoishing commented Jul 11, 2026

Copy link
Copy Markdown

Description

Adds SenseVoice-Small (FunASR) as a selectable on-device ASR model, primarily to provide strong Cantonese transcription (the current Parakeet/Whisper options are weak on Cantonese). SenseVoice-Small is non-autoregressive, runs on the Apple Neural Engine via CoreML, uses ~0.3–0.5 GB RAM, and transcribes far faster than real time.

What's included:

  • SenseVoiceProvider — the 3-stage CoreML pipeline (preprocessor → int8 encoder+CTC → greedy-CTC decode) vendored from FluidInference/FluidAudio (Apache-2.0), since the pinned FluidAudio fork does not ship the SenseVoice module. Core inference runs in an actor off the main thread. Models are downloaded from FluidInference/sensevoice-small-coreml via the existing HuggingFaceModelDownloader. Intel builds get a not-available stub.
  • Language selection — a SenseVoiceLanguage setting (Cantonese / Mandarin / Auto) mapped to the model's lid_int_dict embedding index, with a picker in the voice-engine settings.
  • Long-audio chunking — SenseVoice's CoreML preprocessor caps input at 30 s (3200–480000 samples); recordings longer than that are split at silence-aligned boundaries and stitched, so nothing is dropped.
  • Downloader robustnessHuggingFaceModelDownloader no longer requires metadata.json inside a .mlmodelc bundle (SenseVoice's export omits it); it now validates on coremldata.bin + weights/weight.bin. No regression for existing bundles (Parakeet etc.).

Type of Change

  • 🐞 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 🧹 Chore
  • 📝 Documentation update

Related Issue or Discussion

Closes #582

Testing

  • Tested on Intel Mac (arm64-only feature; Intel path is a compile-time stub)
  • Tested on Apple Silicon Mac
  • Tested on macOS version: 26.5.2
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources
  • Ran formatter locally: swiftformat --config .swiftformat Sources
  • Ran tests locally:

End-to-end verified by driving the exact CoreML pipeline (preprocessor → int8 encoder → CTC decode) against real 16 kHz audio:

  • Cantonese (incl. Cantonese-specific words 喺度 / 嘅 / 靚) transcribed correctly; Mandarin and Auto also verified. The lid index was corrected to yue = 7 (index 13 is <|nospeech|>).
  • Long audio: a 181 s clip transcribes fully via 7 silence-aligned chunks with no truncation or crash; a 28 s clip completes in a single pass. RTF ≈ 0.14.
  • Release build launches and the model downloads + loads from the settings UI.

Screenshots / Video

SenseVoice Small as the active Voice Engine model, showing the language selector (Cantonese / Mandarin / Auto Detect):

SenseVoice Small selected in Voice Engine settings with the Cantonese / Mandarin / Auto Detect language picker

Notes

  • Vendors ~250 lines of Apache-2.0 SenseVoice pipeline code from FluidInference/FluidAudio, with attribution in the file header, because the pinned fork lacks the module. If maintainers prefer, this could instead track an upstream FluidAudio that includes SenseVoice.
  • int8 encoder (~215 MB download) is used by default (accuracy-neutral, ANE).

🤖 Generated with Claude Code

hoishing and others added 6 commits July 11, 2026 16:49
Introduce SettingsStore.SenseVoiceLanguage, the language choice for the
SenseVoiceSmall model.

- Cases: cantonese (default), mandarin, auto.
- embedIndex maps each case to the model's lid_int_dict language embedding
  input (auto=0, mandarin/zh=3, cantonese/yue=13).
- displayName provides localized labels for the settings picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit c70bd973dbe7e80d9d552b6715658ddd48bef144)
Add SenseVoiceSmall as a selectable on-device ASR model with strong
Cantonese accuracy, running on the Apple Neural Engine via CoreML.

SenseVoiceProvider.swift (new):
- Vendors the 3-stage CoreML pipeline + greedy-CTC decode from
  FluidInference/FluidAudio (Apache-2.0), since the pinned FluidAudio fork
  omits the SenseVoice module. Core inference runs in a SenseVoiceEngine
  actor off the main thread.
- Downloads the int8 encoder variant from FluidInference/sensevoice-small-coreml
  via the app's HuggingFaceModelDownloader, reusing its HTML/markup
  corrupt-cache detection.
- Reads the selected language (SettingsStore.senseVoiceLanguage) per call.
- Chunks audio longer than one encoder pass (~100 s cap): splits at the
  quietest sample near the cap to avoid mid-word cuts, transcribes each
  chunk, and joins the pieces so long recordings are fully transcribed.
- Intel builds get a not-available stub.

SettingsStore.swift:
- Add the .senseVoiceSmall SpeechModel case with its metadata (display name,
  download size, accuracy/speed, FunASR provider, brand color) across the
  exhaustive switches; mark it Apple-Silicon-only and non-streaming.
- isInstalled checks the provider's on-disk artifacts on arm64.
- Add the persisted senseVoiceLanguage setting (defaults to Cantonese).

ASRService.swift:
- Route .senseVoiceSmall to a cached SenseVoiceProvider in both the active
  and download-only provider factories, and clear it on reset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 18ae6cd)
Show a language menu (Cantonese / Mandarin / Auto Detect) in the voice
engine settings when the SenseVoice Small model is selected, mirroring the
existing per-model language chip. Selecting a language updates
SettingsStore.senseVoiceLanguage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 0d8d67e6080303dede2fea6ae25526f9bc24cd9a)
artifactIsComplete required metadata.json for every .mlmodelc, but valid
compiled bundles can omit it — the SenseVoice export ships coremldata.bin +
model.mil + weights/weight.bin and no metadata.json. That made
ensureModelsPresent throw "artifacts incomplete" after a fully successful
download and left SpeechModel.isInstalled permanently false.

Require only the load-critical files (coremldata.bin + weights/weight.bin);
metadata.json and model.mil are descriptive and now optional. No regression
for existing bundles (Parakeet etc.), which already contain both required
files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 38e6eea47af64d122235eb4e485e47d09172fdc8)
The CoreML preprocessor constrains its waveform input to 3200..480000
samples (0.2..30 s), rejecting anything longer. The chunk cap was
1,600,000 samples (~100 s), aimed at the encoder's ~108 s frame limit, so
any recording over 30 s crashed with an out-of-range input error.

- Lower maxChunkSamples to 460,000 (28.75 s), safely below 480,000; the
  single-pass path now only ever sees sub-limit audio and long recordings
  chunk correctly.
- Zero-pad waveforms below the 3200-sample floor so very short recordings
  and small trailing chunks don't trip the same input constraint.

Verified end-to-end against the int8 CoreML model: 28 s single-pass and a
181 s recording (7 chunks) both transcribe fully with no crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit dcfbb23e347f13af5934a68ebd357c85c5e145d4)
The Cantonese embedIndex was 13, but 13 is the model's <|nospeech|> control
token; the correct <|yue|> embedding index is 7. Verified against the
model's vocab.json (token <|yue|> maps to embed index 7 in lid_int_dict).

Feeding 13 still transcribed clear Cantonese because the acoustic model
dominates for clean audio, but it passed "nospeech" as the language hint —
wrong semantics that could suppress or degrade output on noisy/borderline
speech. Mandarin (3) and auto (0) were already correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit efeb959e70c3537e5a3e9d4ca2c3b212410a071a)
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

The PR Policy check is blocking this PR because required template information is missing.

Please update the PR description with:

  • Screenshots / Video

Visual files detected:

  • Sources/Fluid/Persistence/SettingsStore+SenseVoiceLanguage.swift
  • Sources/Fluid/Persistence/SettingsStore.swift
  • Sources/Fluid/UI/AISettingsView+SpeechRecognition.swift

Screenshots or video are required for UI, UX, settings, onboarding, overlay, menu bar, or visual behavior changes. If this PR has no visual changes, check the no-visual-change box in the template.

If this remains incomplete for 48 hours after opening, the PR may be closed.

@github-actions github-actions Bot added needs PR template Pull request is missing required template content. needs screenshots Pull request needs screenshot or video evidence. labels Jul 11, 2026
@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds SenseVoice-Small (FunASR) as a selectable on-device ASR model, targeting Cantonese transcription where existing Parakeet/Whisper options are weak. The CoreML pipeline (preprocessor → int8 encoder → greedy CTC) is vendored from FluidInference/FluidAudio under Apache-2.0, pinned to a specific commit SHA, and wired into the existing provider/download infrastructure.

  • SenseVoiceProvider implements the full 3-stage CoreML pipeline inside a Swift actor, with silence-aligned chunking for audio longer than 30 s and a CJK-aware joinChunks helper that avoids spurious inter-character spaces in Chinese output.
  • ModelDownloader validation is relaxed to drop the metadata.json requirement (absent from SenseVoice bundles), while keeping coremldata.bin + weight.bin as the load-critical check — backward-compatible with existing Parakeet bundles.
  • SettingsStore / ASRService / UI changes follow established patterns: new senseVoiceSmall enum case with all metadata filled in, a persisted SenseVoiceLanguage setting (Cantonese / Mandarin / Auto, defaulting to Cantonese), and a language picker in the voice-engine settings that matches the Nemotron/Cohere pickers.

Confidence Score: 5/5

The change is well-scoped, purely additive, and the existing provider infrastructure is not modified beyond the targeted metadata.json relaxation in the downloader.

All changes are purely additive — a new enum case, a new provider, and a downloader tweak. The core inference pipeline faithfully mirrors the upstream FluidAudio implementation and is pinned to a specific commit SHA. The two findings are both narrow edge cases (a missing space at mixed CJK↔Latin chunk boundaries, and a cosmetic progress-event ordering issue) that do not affect correctness for the primary Cantonese use case or any existing functionality.

SenseVoiceProvider.swift — the joinChunks logic and the prepare progress-notification ordering are the only areas that would benefit from a second look.

Reviews (2): Last reviewed commit: "fix(asr): address SenseVoice review — CJ..." | Re-trigger Greptile

Comment thread Sources/Fluid/Services/SenseVoiceProvider.swift Outdated
Comment thread Sources/Fluid/Services/SenseVoiceProvider.swift Outdated
Comment thread Sources/Fluid/Services/SenseVoiceProvider.swift Outdated
@hoishing

Copy link
Copy Markdown
Author
screen 2 @claude update the PR with this screenshot

@github-actions github-actions Bot removed needs PR template Pull request is missing required template content. needs screenshots Pull request needs screenshot or video evidence. labels Jul 11, 2026
… pinned model

Resolve three review findings on the SenseVoice transcription path:

- Chunk join no longer inserts a literal space at every silence cut. A
  content-aware `joinChunks` adds a separator only at Latin-script boundaries;
  CJK (Cantonese/Mandarin/Japanese/Korean) chunks are concatenated directly,
  so long recordings no longer produce output like `喺度 做嘢`.
- transcribe() returns the trimmed text (not the raw string), matching the
  confidence check and the other providers' contract.
- Pin the model download to a specific commit
  (cdea3526163035c19915d4a10268992d018ebd46) instead of `main`, so an upstream
  re-upload can't silently ship a different/incompatible encoder to fresh
  installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hoishing

Copy link
Copy Markdown
Author

Thanks for the review — all three addressed in 16ce3cd:

  1. Space separator corrupts CJK chunk boundaries (the important one) — fixed. Replaced the blanket joined(separator: " ") with a content-aware joinChunks that inserts a space only at Latin-script boundaries; CJK (Cantonese/Mandarin/JA/KO) chunks are concatenated directly. Deciding per boundary from the adjacent characters (rather than the language setting) also handles the Auto-detect case correctly. Verified: ["喺度","做嘢"] → 喺度做嘢, ["hello","world"] → hello world.
  2. Untrimmed text in resulttranscribe() now returns trimmed.
  3. Non-deterministic model revision — pinned to cdea3526163035c19915d4a10268992d018ebd46 instead of main.

Release build passes; the screenshot in the description shows the language picker.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16ce3cd550

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +182 to +184
let end = Self.chunkEnd(in: samples, offset: offset)
let chunk = Array(samples[offset..<end])
let piece = try await engine.transcribe(audio: chunk, language: language)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid transcribing sub-second tail chunks

For recordings only slightly longer than maxChunkSamples (for example, about 29s at 16 kHz), chunkEnd can cut near 460000 and the next loop sends the remaining <1s tail through SenseVoice, despite the minChunkSamples guard. Because runPreprocessor pads short inputs with silence before decoding, that tiny trailing sliver can produce spurious text appended to an otherwise good transcript; choose a cut that leaves at least minChunkSamples, or merge/drop the short tail.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add accurate on-device Cantonese speech-to-text (SenseVoice-Small)

1 participant