Skip to content
Document DB v12 - Improved Interceptors with Soft Delete Integration, AI protections, & Admin UI with Aspire Integration! How!?

MAUI Control

Frameworks
.NET MAUI
Operating Systems
Android
iOS
macOS
Terminal window
dotnet add package Shiny.VoiceIntelligence.Maui

VoiceEnrollmentView is the UI twin of FaceEnrollmentView: set PersonIdentifier, call BeginEnrollment(), and it shows the sentences, counts down, records, checks each clip and keeps going until the voiceprints agree — then raises Completed with the VoiceEnrollmentResult.

xmlns:vi="clr-namespace:Shiny.VoiceIntelligence.Maui;assembly=Shiny.VoiceIntelligence.Maui"
<vi:VoiceEnrollmentView PersonIdentifier="{Binding UserId}"
RecordFor="0:00:05"
Completed="OnEnrolled"
Failed="OnFailed" />
void OnEnrolled(object? sender, VoiceEnrollmentResult e)
=> this.Status = $"Enrolled {e.Speakers.Count} clips, agreement {e.Cohesion:0.00}" +
(e.IsConfident ? "" : " (try again somewhere quieter)");
MemberNotes
PersonIdentifierBindable. The identity to enroll under.
OptionsA VoiceEnrollmentOptions to override prompts and gates. Leave null to derive from the match threshold.
RecordForRecording window per clip. Default 5 s.
CountdownLead-in before recording starts. Default 3 s.
MaxAttemptsRecordings asked for, accepted or not. Default 12.
SessionThe underlying VoiceEnrollmentSession.
Completed / Failed / StepCompletedOutcome and per-clip events.
BeginEnrollment() / CancelEnrollment()Start / stop.

Every judgement — is this clip usable, does it agree, is that enough — stays in VoiceEnrollmentSession in core. The control adds pacing, the sentence list, and stop/failure handling. For a server-side or non-MAUI flow, drive the session directly.

IVoiceRecorder is why the control can exist

Section titled “IVoiceRecorder is why the control can exist”

The rule that put the session in core still holds: no part of this library opens a microphone. Your app implements the seam and registers it; the control resolves it from Handler.MauiContext.Services.

public interface IVoiceRecorder
{
Task<float[]> RecordAsync(TimeSpan duration, CancellationToken ct = default);
// Default implementation forwards to the above, so a recorder that can't report
// levels needs no changes — the VU meter simply stays idle.
Task<float[]> RecordAsync(TimeSpan duration, IProgress<float>? level, CancellationToken ct = default)
=> this.RecordAsync(duration, ct);
}
builder.Services.AddSingleton<IVoiceRecorder, MyVoiceRecorder>();

See Audio Capture for what a correct implementation must and must not do — this is the highest-risk code in the whole feature.

These are documented because each one was a real, observed failure:

  • The whole sentence list is shown from construction, not once a run starts — someone should be able to read what they’ll be asked to say before committing. Each row shows pending, current, kept.
  • The countdown is not cosmetic. Recording the instant a sentence appears captures someone still reading it, and that near-silent clip is exactly what the session then rejects. It’s drawn at 34pt; at 15pt inline it was missed entirely, and a countdown nobody notices isn’t doing its job. A progress bar fills across the recording window alongside it, because “Recording” for five silent seconds gives no sense of how much longer to talk.
  • The VU meter is scaled in dB and marks the gate. Speech RMS around 0.02 and a 0.004 threshold both live in the bottom 3% of a linear bar, where nothing visibly moves. The meter maps −60…0 dBFS across 24 segments, amber below MinSpeechLevel and green above it. Marking the gate is the point: a bare level bar says there is sound, a bar with the threshold on it says whether you are loud enough to be accepted. Ballistics are fast-attack / slow-release, because raw per-chunk RMS flickers on every syllable gap and reads as a fault.
  • A rejected recording stays on the same sentence (the index lives in the session, so the two can’t disagree). Rotating per attempt read as “the wizard is cycling and nothing I do matters”.
  • Restarting mid-run uses a generation guard. A cancelled loop is still parked inside RecordAsync for up to RecordFor; without the guard it wakes up afterwards and writes its prompt index and status over the run that replaced it — observed on-device as sentence 2 marked current with no tick on sentence 1.

Hitting the ceiling stores something rather than nothing: the control calls Session.Finish(), which prunes the worst-disagreeing clips to MinSamples and stores with IsConfident = false. Without that, someone who recorded four usable clips in a noisy room would walk away with no enrollment at all — Submit only writes on the call that completes the session. Below MinSamples it stores nothing and the control reports Failed, because a one-clip “enrollment” is the weak template the session exists to prevent.