This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Monday, 05 January 2026
Där vi bygger en röst till MSK-to-MSK-form system som inte ger nycklarna till AI-riket
Här är det med röstgränssnittet som alla vill ha dem och ingen litar på dem och de har rätt att inte göra det.
De flesta röstformulär misslyckas när modellen hör ett datum eller bestämmer sig för att använda " med hjälp av " och hoppa framåt till . och gå vidare till nästa fält innan du kan korrigera det på . eller ännu värre :.Det hallucinerar självförtroende och automatisk -.Submiterar ditt formulär med fel data på ..
I samma ögonblick som du låter en LLM-enhet styra din formulärflöde till , har du fått en icke-- och deterministisk svart låda till vad som borde vara en förutsägbar användarupplevelse.
Men tänk om vi kunde ha vår kaka och äta det också ? på röstingång för bekvämlighets skull , på LLM för översättningen men Deterministisk kod för allt som faktiskt spelar någon roll
Det där är vad vi bygger idag med ' och bygger idag.- på " på kommandon som överensstämmer med " på röstformulär med hjälp av Blazor Server på , på lokal Whisper för tal.- och - till text., och lokal Ollama för fältutdragning.. och inga molnberoenden... Inga oförutsägbara AI-överherrar.... Bara ren arkitektur...
Innan vi skriver någon kod till , och låter ' på så vis fastställa reglerna.
Här är hur det ser ut i praktiken.
flowchart LR
A[User Speaks] --> B[Whisper STT]
B --> C[Raw Transcript]
C --> D[Ollama LLM]
D --> E[Extracted Value]
E --> F{Validation}
F -->|Pass| G{Policy Check}
F -->|Fail| H[Show Error]
G -->|High Confidence| I[Auto-Confirm]
G -->|Low Confidence| J[Ask User]
J --> K{User Confirms?}
K -->|Yes| L[State Machine: Next Field]
K -->|No| M[Retry Recording]
I --> L
H --> M
style D stroke:#f96,stroke-width:2px
style F stroke:#6f6,stroke-width:2px
style G stroke:#ff9,stroke-width:2px
Lägg märke till vad LLM gör till : på att det översätter till . på att ' är det att ..Statsmaskinen bestämmer flödet... Valideringen bestämmer riktigheten...Den policykontroll som görs är inte LLM på -.Bestämmer om att automatiskt bekräfta eller fråga användaren.
Låt oss vara tydliga om omfattningen av :
Dessa begränsningar är funktioner och inte buggar.De gör systemet förutsägbart.
Vi bygger en fristående Blazor Server-app på . och här är 's arkitektur.
Mostlylucid.VoiceForm/
├── Config/
│ └── VoiceFormConfig.cs # Configuration binding
├── Models/
│ ├── FormSchema/ # Form definitions
│ ├── State/ # Session state
│ └── Extraction/ # LLM input/output
├── Services/
│ ├── Stt/ # Speech-to-text (Whisper)
│ ├── Extraction/ # Field extraction (Ollama)
│ ├── Validation/ # Type-based validators
│ ├── StateMachine/ # Form flow control
│ └── Orchestration/ # Coordinates everything
├── Components/
│ └── Pages/ # Blazor UI
└── wwwroot/js/
└── audio-recorder.js # Web Audio API capture
Arkitekturnoten ska innehålla följande information: Ingen tjänst beror på en annan maskin för " på nedåtläge för ." och utsugaren inte känner till den statliga maskinen på .. Validatorn känner inte till orkestern eller ..Detta gör att varje verk kan testas oberoende av varandra.
Först behöver du Whisper och Ollama som kör lokalt och lägga till dessa till din devdeps-docker-compose.yml:
services:
whisper:
image: onerahmet/openai-whisper-asr-webservice:latest
container_name: whisper-stt
ports:
- "9000:9000"
environment:
- ASR_MODEL=base.en
- ASR_ENGINE=faster_whisper
volumes:
- whisper-models:/root/.cache/huggingface
restart: unless-stopped
ollama:
image: ollama/ollama:latest
container_name: ollama
ports:
- "11434:11434"
volumes:
- ollama-models:/root/.ollama
restart: unless-stopped
volumes:
whisper-models:
ollama-models:
Efter att ha snurrat upp till , ska du dra en modell för Ollama på :
docker exec -it ollama ollama pull llama3.2:3b
Varför det är lokalt? Köra allt lokalt är att MSKK0 och data locality på din CI rörledning kan snurra upp samma containrar på . och dina tester träffade samma slutpunkter på . och ingen API-hastighetsbegränsningar för , och ingen nätverksvariabilitet eller data lämnar din maskin på ..
Anteckningen ska innehålla följande uppgifter: och base.en Whisper-modellen är snabb och korrekt för engelska och engelska för produktion. small.en eller medium.en för bättre noggrannhet
Blanketter definieras i JSON-. och detta är den enda källan till sanning för vilka fält som finns och hur de beter sig
{
"id": "customer-intake",
"name": "Customer Intake Form",
"fields": [
{
"id": "fullName",
"label": "Full Name",
"type": "Text",
"prompt": "Please say your full name.",
"required": true
},
{
"id": "dateOfBirth",
"label": "Date of Birth",
"type": "Date",
"prompt": "What is your date of birth?",
"required": true,
"confirmationPolicy": {
"alwaysConfirm": true
}
},
{
"id": "email",
"label": "Email Address",
"type": "Email",
"prompt": "What is your email address?",
"required": true
},
{
"id": "phone",
"label": "Phone Number",
"type": "Phone",
"prompt": "What is your phone number?",
"required": false
},
{
"id": "notes",
"label": "Additional Notes",
"type": "Text",
"prompt": "Any additional notes?",
"required": false
}
]
}
De modeller som representerar detta
public record FormDefinition(
string Id,
string Name,
List<FieldDefinition> Fields);
public record FieldDefinition(
string Id,
string Label,
FieldType Type,
string Prompt,
bool Required = true,
ConfirmationPolicy? ConfirmationPolicy = null);
public enum FieldType
{
Text,
Date,
Email,
Phone,
Choice // Constrained vocabulary - especially important for voice
}
public record ConfirmationPolicy(
bool AlwaysConfirm = false,
double ConfidenceThreshold = 0.85);
För att schemat är deklarativt. att lägga till fält kräver aldrig att röra statsmaskinenSläpp en ny fältdefinition i JSON-, och formuläret innehåller automatiskt den med rätt flödeskontroll.
Här är kärninsikten. den statliga maskinen använder inte AIDet är en ren logik som bestämmer övergångar baserade på explicita regler.
public class FormStateMachine : IFormStateMachine
{
private FormSession _session = null!;
private int _currentFieldIndex;
public FormSession StartSession(FormDefinition form)
{
_session = new FormSession
{
Id = Guid.NewGuid().ToString(),
Form = form,
Status = FormStatus.InProgress,
StartedAt = DateTime.UtcNow,
FieldStates = form.Fields.ToDictionary(
f => f.Id,
f => new FieldState { FieldId = f.Id, Status = FieldStatus.Pending })
};
// First field starts in progress
if (form.Fields.Count > 0)
{
_session.FieldStates[form.Fields[0].Id].Status = FieldStatus.InProgress;
}
return _session;
}
public FieldDefinition? GetCurrentField()
{
if (_currentFieldIndex >= _session.Form.Fields.Count)
return null;
return _session.Form.Fields[_currentFieldIndex];
}
}
De statliga övergångarna är explicita och testerbara till :.
public StateTransitionResult ProcessExtraction(
ExtractionResponse extraction,
ValidationResult validation)
{
var currentField = GetCurrentField();
if (currentField == null)
return new StateTransitionResult(false, "No current field");
var fieldState = _session.FieldStates[currentField.Id];
fieldState.AttemptCount++;
// Validation failed? Stay on current field
if (!validation.IsValid)
{
return new StateTransitionResult(
false,
$"Validation failed: {validation.ErrorMessage}");
}
// Store the pending value
fieldState.PendingValue = extraction.Value;
fieldState.PendingConfidence = extraction.Confidence;
// Check confirmation policy - this is rules, not AI
var policy = currentField.ConfirmationPolicy
?? new ConfirmationPolicy();
var needsConfirmation = policy.AlwaysConfirm
|| extraction.Confidence < policy.ConfidenceThreshold;
if (needsConfirmation)
{
fieldState.Status = FieldStatus.AwaitingConfirmation;
return new StateTransitionResult(
true,
"Please confirm this value",
RequiresConfirmation: true);
}
// Auto-confirm high confidence values
return ConfirmValue();
}
Huvudpunkt på föredragningslistan och _currentFieldIndex Endast förskott på bekräftelse på , på extraktionen detta innebär en misslyckad extraktion eller avvisad bekräftelse håller dig på samma fält .. . Användaren stannar i kontroll .. .
Lägg märke till konfirmationslogiken för att visa att det är en enkel policykontroll och inte ett LLM-resonemang. alwaysConfirm Be användaren om att få ett svar på din fråga.
Ollama-extraktorn har ett jobb och förvandlar rörigt mänskligt tal till strukturerade fältvärden.
public interface IFieldExtractor
{
Task<ExtractionResponse> ExtractAsync(
ExtractionContext context,
CancellationToken ct = default);
}
public record ExtractionContext(
FieldDefinition Field,
string Prompt,
string Transcript);
public record ExtractionResponse(
string FieldId,
string? Value,
double Confidence,
bool NeedsConfirmation, // Suggestion only - policy has final say
string? Reason);
Viktigt att tänka på Extraheraren kan föreslå NeedsConfirmation: trueMen konfirmeringspolicyn har alltid det sista ordet för . på LLM-' och dess yttrande är rådgivande och inte auktoritativt.
Och genomförandet
public class OllamaFieldExtractor : IFieldExtractor
{
private readonly HttpClient _httpClient;
private readonly string _model;
public async Task<ExtractionResponse> ExtractAsync(
ExtractionContext context,
CancellationToken ct = default)
{
var systemPrompt = BuildSystemPrompt(context.Field);
var userPrompt = $"User said: \"{context.Transcript}\"";
var request = new
{
model = _model,
messages = new[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
},
format = "json",
stream = false,
options = new { temperature = 0.1 } // Low = deterministic
};
var response = await _httpClient.PostAsJsonAsync(
"/api/chat", request, ct);
return ParseResponse(response, context.Field.Id);
}
}
Varför temperaturen är så hög Vi vill att LLM ska vara tråkiga och förutsägbara med tanke på samma utskrift av , ska det extrahera samma värde varje gång . och temperatur på 0.1 och minimerar den kreativa variationen på - och exakt vad vi vill ha för dataextraktion.
Systemprompten är explicit om LLM-' och dess begränsade roll
private string BuildSystemPrompt(FieldDefinition field)
{
return $"""
You are a data extraction assistant. Extract the {field.Label}
from the user's speech.
Field type: {field.Type}
Return JSON only:
{{
"fieldId": "{field.Id}",
"value": "<extracted value or null>",
"confidence": <0.0-1.0>,
"needsConfirmation": <true/false>,
"reason": "<brief explanation>"
}}
Rules:
- For dates, output ISO format (YYYY-MM-DD)
- For emails, output lowercase
- For phones, output digits only
- If you can't extract, set value to null
- Be conservative with confidence scores
DO NOT:
- Ask follow-up questions
- Suggest next steps
- Make assumptions beyond the transcript
""";
}
LLM-utdragen till , och rapporterar konfidens Det bestämmer inte vad som händer härnäst.
JavaScript-sidan fångar mikrofonljud och konverterar det till 16 på Hz mono WAV-:
window.voiceFormAudio = (function () {
let mediaRecorder = null;
let audioChunks = [];
let audioContext = null;
let dotNetRef = null;
async function startRecording() {
const stream = await navigator.mediaDevices
.getUserMedia({ audio: true });
audioContext = new AudioContext();
audioChunks = [];
const options = { mimeType: 'audio/webm;codecs=opus' };
mediaRecorder = new MediaRecorder(stream, options);
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
stream.getTracks().forEach(track => track.stop());
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
const wavBlob = await convertToWav(audioBlob);
const wavBytes = await wavBlob.arrayBuffer();
// Send to Blazor
const uint8Array = new Uint8Array(wavBytes);
await dotNetRef.invokeMethodAsync(
'OnRecordingComplete',
Array.from(uint8Array));
};
mediaRecorder.start(100);
}
return { initialize, startRecording, stopRecording };
})();
Varför konvertera till WAV-? på Whisper accepterar olika format på , och men XSK1kHz mono WAV är optimal och - och ' är exakt vad modellen utbildades på utan omkodning på servern, , och konsekventa resultat och konverteringen är deterministisk.
och convertToWav Funktions-omprovtagningar till 16 och Hz på - och ' kommer att spara WAV-huvudet bit för - och twiddling på , på dig, men det är ' på repo och .
UI visar det aktuella fältet tydligt på vänster sida med alla fält synliga i en sidofält på höger sida Sidofältet är viktigt för tillitAnvändarna kan se var de befinner sig i formuläret och vad de har svarat på , och vad som kommer härnäst utan överraskningar.
graph LR
subgraph "Left Panel"
A[Current Field Prompt]
B[Record Button]
C[Transcript Display]
D[Confirmation Dialog]
end
subgraph "Right Sidebar"
E[Field 1: Full Name ✓]
F[Field 2: DOB - Active]
G[Field 3: Email - Pending]
H[Field 4: Phone - Pending]
end
style F stroke:#36f,stroke-width:2px
style E stroke:#6f6,stroke-width:2px
Här är Blazors sidstruktur.
@page "/voiceform/{FormId}"
@inject IFormOrchestrator Orchestrator
@rendermode InteractiveServer
<div class="top-bar">
<h1>Voice Form</h1>
<button class="theme-toggle" onclick="voiceFormTheme.toggle()">
Dark Mode
</button>
</div>
<main>
<div class="voice-form-layout">
<!-- Left: Active Field -->
<div class="active-field-panel">
@if (_currentField != null)
{
<div class="current-prompt">
<h2>@_currentField.Label</h2>
<p class="prompt-text">@_message</p>
</div>
<AudioRecorder OnAudioCaptured="HandleAudioCaptured"
IsRecording="_isRecording"
IsProcessing="_isProcessing" />
@if (!string.IsNullOrEmpty(_transcript))
{
<TranscriptDisplay Transcript="_transcript"
Confidence="_transcriptConfidence" />
}
@if (_showConfirmation)
{
<ConfirmationDialog ExtractedValue="_pendingValue"
OnConfirm="HandleConfirm"
OnReject="HandleReject" />
}
}
</div>
<!-- Right: Form Overview -->
<div class="form-sidebar">
<h3>@_session.Form.Name</h3>
<div class="form-fields-list">
@foreach (var field in _session.Form.Fields)
{
var state = _session.GetFieldState(field.Id);
<div class="form-field-item @GetFieldClass(field, state)">
<div class="field-status-icon">
@GetStatusIcon(state.Status)
</div>
<div class="field-info">
<div class="field-name">@field.Label</div>
<div class="field-value">
@(state.Value ?? "Waiting")
</div>
</div>
</div>
}
</div>
</div>
</div>
</main>
Orkestratorn samordnar de tjänster den har ingen egen förgreningslogikDet bara ringer tjänster i följd och passerar resultat längs med :
public class FormOrchestrator : IFormOrchestrator
{
private readonly ISttService _sttService;
private readonly IFieldExtractor _extractor;
private readonly IFormValidator _validator;
private readonly IFormStateMachine _stateMachine;
private readonly IFormEventLog _eventLog;
public async Task<ProcessingResult> ProcessAudioAsync(byte[] audioData)
{
var currentField = _stateMachine.GetCurrentField();
if (currentField == null)
return ProcessingResult.Error("No current field");
// Step 1: Speech to text
var sttResult = await _sttService.TranscribeAsync(audioData);
await _eventLog.LogAsync(new TranscriptReceivedEvent(
currentField.Id, sttResult.Transcript, sttResult.Confidence));
// Step 2: Extract structured value
var context = new ExtractionContext(
currentField, currentField.Prompt, sttResult.Transcript);
var extraction = await _extractor.ExtractAsync(context);
await _eventLog.LogAsync(new ExtractionAttemptEvent(
currentField.Id, extraction));
// Step 3: Validate
var validation = _validator.Validate(currentField, extraction.Value);
// Step 4: State machine decides next step
var transition = _stateMachine.ProcessExtraction(extraction, validation);
return new ProcessingResult(
Success: transition.Success,
Message: transition.Message,
Session: _stateMachine.GetSession(),
RequiresConfirmation: transition.RequiresConfirmation,
PendingValue: extraction.Value);
}
}
Varje steg är oberoende och testbar till . på State Machine Rör aldrig nätverket"Mästare" "LLM" Rör aldrig vid staten..
Mörkt läge implementeras med anpassade CSS-egenskaper och system
:root {
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--text-primary: #1e293b;
--accent-blue: #3b82f6;
--accent-green: #22c55e;
}
[data-theme="dark"] {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--text-primary: #f1f5f9;
--accent-blue: #60a5fa;
--accent-green: #4ade80;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
}
Temat att växla kvarstår via localStorage och : på lokal nivå
window.voiceFormTheme = (function () {
const THEME_KEY = 'voiceform-theme';
function toggle() {
const current = document.documentElement
.getAttribute('data-theme') || 'light';
const newTheme = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem(THEME_KEY, newTheme);
}
// Initialize from saved preference or system preference
function init() {
const saved = localStorage.getItem(THEME_KEY);
const systemDark = window.matchMedia(
'(prefers-color-scheme: dark)').matches;
const theme = saved || (systemDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
}
return { toggle, init };
})();
Den deterministiska arkitekturen lönar sig i uttestningsutgåvan av . och här är ett tillståndsmaskins-enhetstest för den lyckliga vägen till :
[Fact]
public void ProcessExtraction_HighConfidence_AutoConfirms()
{
// Arrange
var form = CreateTestForm();
var stateMachine = new FormStateMachine();
stateMachine.StartSession(form);
var extraction = new ExtractionResponse(
FieldId: "fullName",
Value: "John Smith",
Confidence: 0.95, // High confidence
NeedsConfirmation: false,
Reason: "Clear speech");
var validation = ValidationResult.Success();
// Act
var result = stateMachine.ProcessExtraction(extraction, validation);
// Assert
result.Success.Should().BeTrue();
result.RequiresConfirmation.Should().BeFalse();
var fieldState = stateMachine.GetSession()
.GetFieldState("fullName");
fieldState.Status.Should().Be(FieldStatus.Confirmed);
fieldState.Value.Should().Be("John Smith");
}
Och här är ett negativt test med lågt självförtroende. krafter bekräftelse oavsett vad LLM föreslår
[Fact]
public void ProcessExtraction_LowConfidence_RequiresConfirmation()
{
// Arrange
var form = CreateTestForm();
var stateMachine = new FormStateMachine();
stateMachine.StartSession(form);
var extraction = new ExtractionResponse(
FieldId: "fullName",
Value: "John Smith",
Confidence: 0.65, // Below threshold
NeedsConfirmation: false, // LLM says no, but policy overrides
Reason: "Noisy audio");
var validation = ValidationResult.Success();
// Act
var result = stateMachine.ProcessExtraction(extraction, validation);
// Assert
result.RequiresConfirmation.Should().BeTrue(
"Policy threshold (0.85) should override LLM suggestion");
var fieldState = stateMachine.GetSession()
.GetFieldState("fullName");
fieldState.Status.Should().Be(FieldStatus.AwaitingConfirmation);
}
Och ett PuppeterSharp browser test som faktiskt klickar på saker och ting
[Fact]
public async Task HomePage_ThemeToggle_ShouldSwitchTheme()
{
await _page!.GoToAsync(BaseUrl);
await _page.WaitForSelectorAsync(".theme-toggle");
var initialTheme = await _page.EvaluateFunctionAsync<string?>(
"() => document.documentElement.getAttribute('data-theme')");
// Click toggle
await _page.ClickAsync(".theme-toggle");
await Task.Delay(100);
var newTheme = await _page.EvaluateFunctionAsync<string?>(
"() => document.documentElement.getAttribute('data-theme')");
newTheme.Should().NotBe(initialTheme);
}
[Fact]
public async Task VoiceFormPage_Sidebar_ShouldShowAllFields()
{
await _page!.GoToAsync($"{BaseUrl}/voiceform/customer-intake");
await _page.WaitForSelectorAsync(".form-fields-list");
var fieldItems = await _page.QuerySelectorAllAsync(".form-field-item");
fieldItems.Should().HaveCount(5, "Customer intake form has 5 fields");
}
Den fullständiga testsviten kör tester av enheten för affärslogiken med , och 22 på UI-. på webbläsarens integrationstest.
Låt oss återbesöka varför vi byggde det så här.
graph TD
subgraph "Traditional Voice Form"
A1[User Speaks] --> B1[LLM]
B1 --> C1[LLM decides field]
C1 --> D1[LLM validates]
D1 --> E1[LLM confirms]
E1 --> F1[LLM advances form]
end
subgraph "Ten Commandments Approach"
A2[User Speaks] --> B2[Whisper STT]
B2 --> C2[Ollama Extract]
C2 --> D2[C# Validator]
D2 --> E2[Policy Check]
E2 --> F2[State Machine]
end
style B1 stroke:#f96,stroke-width:2px
style C1 stroke:#f96,stroke-width:2px
style D1 stroke:#f96,stroke-width:2px
style E1 stroke:#f96,stroke-width:2px
style F1 stroke:#f96,stroke-width:2px
style C2 stroke:#f96,stroke-width:2px
style D2 stroke:#6f6,stroke-width:2px
style E2 stroke:#6f6,stroke-width:2px
style F2 stroke:#6f6,stroke-width:2px
I det traditionella tillvägagångssättet Allt är LLMVarje beslut är icke-- påfrestande . och varje test är probabilistisk.. på varje fel är och det gör bara ibland att "- på ett oreproducerbart och oförklarligt.
I vårt tillvägagångssätt gör LLM **En sak till.**Allt annat är deterministiskt C.M.S.K.S.C.S.C.S.C.S.S.C.S.S.C.S.S.S.S.T.T.S.S.T.S.S.S.T.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.
Detta är budorden och 3, och 4 på action och : på LLM översätts till , på statskontrollerna flow på , på policyn bestämmer bekräftelsen av . på detta sätt.
docker compose -f devdeps-docker-compose.yml up -d
docker exec -it ollama ollama pull llama3.2:3b
cd Mostlylucid.VoiceForm
dotnet run
Navigera till http://localhost:5000
Klicka på "MSK" Customer Intake och börja prata !
Röstgränssnitt behöver inte vara svarta boxar och . på tal Genom att behandla tal som bara en annan indatametod eller LLMs som översättare och hålla flödeskontrollen i deterministisk kod så får du :
De tre budorden är inte om att vara anti-- och AIB. och de handlar om sätta AI på sin rätta platsEtt kraftfullt verktyg som översätter mellan mänsklig rörighet och datorprecision och inte ett orakel som fattar beslut för oss.
Gå nu och bygg något där du inte modellen eller - och bestäm vad som händer härnäst.
Just nu kan användaren läsa uppmaningar och talar svar men vad händer om systemet kan tala tillbakaDel B1 kommer att lägga till text på - till - på tal feedback på , som gör detta till en fullständig IVR på ( på Interactive Voice Response på ) och :
Arkitekturen är redan förberedda på : och den statliga maskinen avger händelser på ,. Orkestratorn samordnar tjänster på . och lägger till röstutmatning är bara en annan tjänst som lyssnar på dessa händelser.
Kommer snart.
Den fullständiga källkoden finns i Mostlylucid.VoiceForm Projekt inom ramen för repo- och msk-programmet
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.