Recursive AI

Getting started with OneSpeech

All onespeech docs

Install OneSpeech for .NET 10, transcribe a recording with timed words and speaker turns, run a live session from packaged models with no network, and use the onespeech command-line tool. Every program below was run on the 1.0.0 packages from NuGet, and the output shown is what it printed.

Install

OneSpeech targets net10.0, so you need the .NET 10 SDK.

Terminal
dotnet new console -o SpeechStart
cd SpeechStart
dotnet add package RecursiveAI.OneSpeech

The core package depends on RecursiveAI.OneAudio (decoding and resampling) and RecursiveAI.OneModel (inference) only, and none of the three contains native code. Silero VAD is embedded in it. Every other model either comes in its own package or downloads on first use into a per-user cache, where it is verified against the catalog’s size and SHA-256.

The samples use a LibriSpeech test-clean recording that ships in the OneSpeech repository as testdata/1089-134686-0001.flac (CC BY 4.0; about three seconds of one speaker). LibriSpeech’s reference text is “stuff it into you his belly counselled him”.

Transcribe a file

Program.cs:

C#
using OneSpeech;
 
Transcript transcript = Speech.Transcribe("1089-134686-0001.flac", language: "en");
 
Console.WriteLine(transcript.Text);
Console.WriteLine($"{transcript.Language}, {transcript.Duration.TotalSeconds:0.00} s");
 
foreach (Word word in transcript.Words.Take(6))
    Console.WriteLine($"{word.Start?.TotalSeconds:0.00}–{word.End?.TotalSeconds:0.00}  {word.Text}  ({word.TimingMethod})");
 
foreach (Turn turn in transcript.Turns)
    Console.WriteLine($"{turn.Speaker.Id}: {turn.Start.TotalSeconds:0.00}–{turn.End.TotalSeconds:0.00}");
 
foreach (ModelInfo model in transcript.Models)
    Console.WriteLine($"{model.Id} ({model.License})");
 
File.WriteAllText("transcript.json", transcript.ToJson());

dotnet run prints:

Text
 stuffered into you, his belly counseled him.
en, 3.28 s
0.55–0.87   stuffered  (ForcedAligner)
0.87–1.19   into  (ForcedAligner)
1.19–1.43   you,  (ForcedAligner)
1.59–1.75   his  (ForcedAligner)
1.75–2.15   belly  (ForcedAligner)
2.15–2.63   counseled  (ForcedAligner)
speaker-1: 0.20–3.09
silero/vad-v6.2.3 (MIT)
openai/whisper-large-v3-turbo (MIT)
Qwen/Qwen3-ForcedAligner-0.6B (Apache-2.0)
iic/speech_campplus_sv_en_voxceleb_16k (Apache-2.0)

This is the default file model’s real output, mistake included: Whisper large-v3-turbo heard “stuff it” as “stuffered”. Word text keeps Whisper’s leading space.

Models lists everything that took part. The model argument selects one role, not the whole pipeline: turbo recognized the words, Qwen3-ForcedAligner timed them, and CAM++ English labeled the speaker. The first run downloaded and converted all of them, several GB, and took about two minutes; later runs reuse the cache. To prepare a machine ahead of time, call await Speech.PrefetchAsync(language: "en", model: "openai/whisper-large-v3-turbo"). First-use models lists every download by verb.

A speaker label such as speaker-1 is anonymous and local to this transcript, not a person’s identity. Words that can’t be timed have a null Start and End; a time is never invented. transcript.json is the Transcript JSON schema, version 1.

A live session, from packages, offline

Speech.Listen reads mono 16 kHz PCM that you supply and returns partials, finals, endpoints and speaker turns on one audio clock. It doesn’t capture a microphone: an app pushes its own capture. Here the blocks come from the same file, 100 ms at a time.

The live default, Moonshine streaming tiny, and the CAM++ English speaker embedder each ship as a NuGet package, so this project needs no download at runtime:

Terminal
dotnet new console -o SpeechLive
cd SpeechLive
dotnet add package RecursiveAI.OneSpeech
dotnet add package RecursiveAI.OneSpeech.Models.MoonshineStreamingTiny
dotnet add package RecursiveAI.OneSpeech.Models.CamPlusEnglish

Program.cs:

C#
using OneSpeech;
using OneSpeech.Audio;
 
await foreach (LiveEvent update in Speech.Listen(Blocks("1089-134686-0001.flac"), language: "en"))
{
    switch (update)
    {
        case PartialEvent partial:
            Console.WriteLine($"{partial.AudioTime.TotalSeconds:0.0}s  draft {partial.Revision}: {partial.Segment.Text}");
            break;
        case FinalEvent final:
            Console.WriteLine($"{final.AudioTime.TotalSeconds:0.0}s  final: {final.Segment.Text}");
            break;
        case EndpointEvent endpoint:
            Console.WriteLine($"{endpoint.AudioTime.TotalSeconds:0.0}s  endpoint ({endpoint.Reason})");
            break;
        case SpeakerTurnEvent turn:
            Console.WriteLine($"{turn.AudioTime.TotalSeconds:0.0}s  speaker {turn.Turn.Speaker.Id} {turn.Turn.Start.TotalSeconds:0.00}–{turn.Turn.End.TotalSeconds:0.00}");
            break;
    }
}
 
static async IAsyncEnumerable<AudioBuffer> Blocks(string path)
{
    using AudioFileSource source = AudioFileSource.Open(path, CancellationToken.None);
    while (true)
    {
        float[] block = new float[1600 * source.Channels]; // 100 ms at 16 kHz
        int read = source.Read(block, CancellationToken.None);
        if (read == 0) yield break;
        yield return new AudioBuffer(block.AsMemory(0, read), AudioFileSource.OutputSampleRate, source.Channels);
        await Task.Yield();
    }
}

Run with an empty model cache and no network at all, it prints:

Text
0.9s  draft 1: Duff
1.1s  draft 2: Duff it
1.3s  draft 3: Stuff it into
1.5s  draft 4: Stuff it into you.
1.7s  draft 5: Stuff it into you.
1.9s  draft 6: Stuff it into you, his
2.1s  draft 7: Stuff it into you, his belly.
2.3s  draft 8: Stuff it into you, his belly came.
2.5s  draft 9: Duff it into you, his belly counts.
2.7s  draft 10: Stuff it into you, his belly counselled.
2.9s  draft 11: Stuff it into you, his belly counseled him.
3.1s  speaker speaker-1 0.55–2.05
3.1s  speaker speaker-1 2.05–2.91
3.1s  final: Stuff it into you, his belly counseled him.
3.1s  endpoint (Silence)

Each draft replaces the one before it; the final is immutable. AudioTime is how much audio had been accepted when the event became available, not a word boundary. On build, the model packages copy their files into onespeech-models/ beside the executable; keep that directory when you deploy. A model package covers only its own model: Speech.Transcribe on files can still need an aligner that isn’t packaged.

The command-line tool

RecursiveAI.OneSpeech.Cli is the onespeech .NET tool:

Terminal
dotnet new tool-manifest
dotnet tool install RecursiveAI.OneSpeech.Cli
dotnet onespeech transcribe 1089-134686-0001.flac --language en --format srt

It prints:

Text
1
00:00:00,546 --> 00:00:02,786
 stuffered into you, his belly counseled
 him.

--format takes text, json, srt or vtt, and --output writes a file instead. onespeech --help lists align, diarize, detect-language, models list and models pull, and the speaker commands. For containers such as MP4 and MKV, and for ASS and TTML captions in your own code, add RecursiveAI.OneSpeech.Media and call MediaInput.Register() once; see Containers and captions.

Speakers

The default diarizer labels who spoke when, but it doesn’t detect overlapping speech. NemotronDiarizer is an overlap-aware upgrade for up to eight speakers; it is a tier-2 model that loads only from its publisher’s files, by name, never by default. See Diarization.

Speech.Enroll, Speech.Identify and Speech.Verify recognize enrolled voices; enrollments live only in process memory. In the README’s words: “Speaker embeddings are biometric data. Obtain informed consent, limit retention and access, and check applicable law, including Illinois BIPA and GDPR Article 9, before enrollment, identification or verification.” See Speaker recognition.

Where next