This commit is contained in:
2026-04-09 10:21:20 +02:00
parent 4c2345c176
commit ecebf9f5c1
10 changed files with 1040 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
package audiocap
import (
"context"
"fmt"
"io"
"log"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"time"
speech "cloud.google.com/go/speech/apiv2"
"cloud.google.com/go/speech/apiv2/speechpb"
"google.golang.org/api/option"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
sampleRate = 16000
chunkBytes = 3200 // 100ms @ 16kHz mono s16le
streamMaxAge = 4 * time.Minute // Googles max age is 5 min
audioDevice = "@DEFAULT_MONITOR@"
location = "europe-west4"
languageCode = "en-US"
endpoint = "europe-west4-speech.googleapis.com:443"
)
var responder *Responder
func (r *Responder) Close() error {
if r.genaiClient != nil {
return r.genaiClient.Close()
}
return nil
}
func Start() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
mic, err := Setup()
if err != nil {
log.Fatalf("virtmic: %v", err)
}
defer mic.Teardown()
log.Printf("%s🎤 Virtmic ready: select '%s' as input in your call app%s",
Yellow, SourceName, Reset)
// 2. TTS client
ttsClient, err := New(ctx, mic.SinkForPlayback())
if err != nil {
log.Fatalf("tts: %v", err)
}
defer ttsClient.Close()
client, err := speech.NewClient(ctx, option.WithEndpoint(endpoint))
if err != nil {
log.Fatalf("speech client: %v", err)
}
defer client.Close()
responder, err = NewResponder(ctx, ttsClient)
if err != nil {
log.Fatalf("responder: %v", err)
}
defer responder.Close()
go responder.ResponseDaemon(ctx)
audio, err := startCapture(ctx)
if err != nil {
log.Fatalf("capture: %v", err)
}
defer audio.Close()
recognizer := fmt.Sprintf("projects/%s/locations/%s/recognizers/_", "cheater-492707", location)
log.Printf("🎙 Listening on %s → %s", audioDevice, recognizer)
if err := run(ctx, client, recognizer, audio); err != nil && ctx.Err() == nil {
log.Fatalf("run: %v", err)
}
log.Println("👋 Bye")
}
// startCapture spawns parec and returns its stdout as raw PCM.
func startCapture(ctx context.Context) (io.ReadCloser, error) {
cmd := exec.CommandContext(ctx, "parec",
"-d", audioDevice,
"--format=s16le",
"--rate=16000",
"--channels=1",
"--latency-msec=20",
)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
cmd.Stderr = log.Writer()
if err := cmd.Start(); err != nil {
return nil, err
}
return stdout, nil
}
// run reads audio into a channel and dispatches it to rotating streams.
func run(ctx context.Context, client *speech.Client, recognizer string, audio io.Reader) error {
audioCh := make(chan []byte, 32)
// Audio pump — never blocks on stream send
go func() {
defer close(audioCh)
buf := make([]byte, chunkBytes)
for {
if ctx.Err() != nil {
return
}
n, err := io.ReadFull(audio, buf)
if err != nil && err != io.ErrUnexpectedEOF {
log.Printf("❌ audio read: %v", err)
return
}
if n == 0 {
continue
}
chunk := make([]byte, n)
copy(chunk, buf[:n])
select {
case audioCh <- chunk:
case <-ctx.Done():
return
}
}
}()
var (
mu sync.Mutex
current *activeStream
rotateAt = time.Now().Add(streamMaxAge)
)
openNew := func() error {
s, err := newStream(ctx, client, recognizer)
if err != nil {
return err
}
mu.Lock()
old := current
current = s
rotateAt = time.Now().Add(streamMaxAge)
mu.Unlock()
if old != nil {
go old.close()
}
return nil
}
if err := openNew(); err != nil {
return fmt.Errorf("open initial stream: %w", err)
}
for {
select {
case <-ctx.Done():
mu.Lock()
if current != nil {
current.close()
}
mu.Unlock()
return ctx.Err()
case chunk, ok := <-audioCh:
if !ok {
return nil
}
mu.Lock()
needRotate := time.Now().After(rotateAt)
mu.Unlock()
if needRotate {
log.Println("🔄 Rotating stream")
if err := openNew(); err != nil {
log.Printf("❌ rotate: %v", err)
}
}
mu.Lock()
s := current
mu.Unlock()
if err := s.send(chunk); err != nil {
log.Printf("⚠️ send: %v — reopening", err)
if err := openNew(); err != nil {
log.Printf("❌ reopen: %v", err)
}
}
}
}
}
// activeStream wraps a v2 streaming session and dedupes transcript output.
type activeStream struct {
stream speechpb.Speech_StreamingRecognizeClient
cancel context.CancelFunc
mu sync.Mutex
closed bool
lastInterim string // last printed interim, used to compute deltas
}
func newStream(parent context.Context, client *speech.Client, recognizer string) (*activeStream, error) {
ctx, cancel := context.WithCancel(parent)
stream, err := client.StreamingRecognize(ctx)
if err != nil {
cancel()
return nil, err
}
configReq := &speechpb.StreamingRecognizeRequest{
Recognizer: recognizer,
StreamingRequest: &speechpb.StreamingRecognizeRequest_StreamingConfig{
StreamingConfig: &speechpb.StreamingRecognitionConfig{
Config: &speechpb.RecognitionConfig{
DecodingConfig: &speechpb.RecognitionConfig_ExplicitDecodingConfig{
ExplicitDecodingConfig: &speechpb.ExplicitDecodingConfig{
Encoding: speechpb.ExplicitDecodingConfig_LINEAR16,
SampleRateHertz: sampleRate,
AudioChannelCount: 1,
},
},
Model: "chirp_2",
LanguageCodes: []string{languageCode},
Features: &speechpb.RecognitionFeatures{
EnableAutomaticPunctuation: true,
},
},
StreamingFeatures: &speechpb.StreamingRecognitionFeatures{
InterimResults: true,
},
},
},
}
if err := stream.Send(configReq); err != nil {
cancel()
return nil, err
}
as := &activeStream{stream: stream, cancel: cancel}
// Receive loop
go func() {
defer cancel()
for {
resp, err := stream.Recv()
if err == io.EOF {
return
}
if err != nil {
if st, ok := status.FromError(err); ok && st.Code() == codes.Canceled {
return
}
log.Printf("❌ recv: %v", err)
return
}
for _, result := range resp.Results {
if len(result.Alternatives) == 0 {
continue
}
as.handleTranscript(result.Alternatives[0].Transcript, result.IsFinal)
}
}
}()
return as, nil
}
func (s *activeStream) send(chunk []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return fmt.Errorf("stream closed")
}
return s.stream.Send(&speechpb.StreamingRecognizeRequest{
StreamingRequest: &speechpb.StreamingRecognizeRequest_Audio{
Audio: chunk,
},
})
}
func (s *activeStream) close() {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
s.mu.Unlock()
_ = s.stream.CloseSend()
time.AfterFunc(2*time.Second, s.cancel)
}
// handleTranscript prints only the new part of a growing interim transcript.
func (s *activeStream) handleTranscript(text string, final bool) {
s.mu.Lock()
defer s.mu.Unlock()
if final {
delta := strings.TrimSpace(strings.TrimPrefix(text, s.lastInterim))
if delta != "" {
fmt.Printf("%s✓ %s%s\n", Red, delta, Reset)
}
s.lastInterim = ""
responder.GetResponse(text)
return
}
if after, ok := strings.CutPrefix(text, s.lastInterim); ok {
delta := strings.TrimSpace(after)
if delta != "" {
fmt.Printf("… %s\n", delta)
}
} else {
// Model revised earlier text — reprint full line
fmt.Printf("… %s\n", text)
}
s.lastInterim = text
}
+72
View File
@@ -0,0 +1,72 @@
package audiocap
const userCV = `Karl Breuer
Dipl.-Ing. | Freelance Software Developer
+49 160 2057504 | mail@karlbreuer.com
Wegscheiderstr. 5, 06110 Halle, Germany
Profile
Fullstack developer and graduated engineer (Dipl.-Ing.).
For over 10 years I have been working at the intersection of engineering and software, first
in industry at Siemens and KSB, now as a freelancer.
What I do: translate complex problems into clean, maintainable software. Whether as a
technical lead within a team or as a developer who takes a project from architecture to
deployment on my own.
My background in process engineering, thermodynamics, and industrial project
management helps me get up to speed quickly in new domains. But even without a
technical subject domain, I bring what matters:
I deliver results, on time and on target.
Technology Stack
Frontend: React, TypeScript, Vite, JavaScript, Tailwind CSS, HTML, CSS
Backend: Go, Python, Django, PostgreSQL, MariaDB, Redis, REST APIs, WebSocket
DevOps & Infrastructure: Docker, Linux, Git, GitLab CI/CD, VPS
Architecture: Microservices, Real-time Systems, Legacy Migration, AI/LLM Integration,
RAG Systems, AI Agents
Selected Freelance Projects
Calculation Platform | 2025 PWA; real-time collaboration, revision safety.
https://karlbreuer.com/blog/ktool/en | React, Vite, Go, PostgreSQL, WebSocket
AI Interaction Platform | 2024 Extended and customized OpenWebUI with AI agents
and RAG capabilities for enterprise use. Tech: OpenWebUI, FastAPI, React, TypeScript,
Go, Docker, PostgreSQL, SQLite3, Azure OpenAI, MS Entra
Financial Services Web Application | 2024 Architected and developed a high-
performance web application for the financial sector. Tech: React, TypeScript, Go
(Go4lage), Docker, PostgreSQL, Linux
Railway Industry Data Management System | 2023 Present Built a robust CRUD
application for data management in the railway sector. Tech: JavaScript, jQuery, Django,
Docker, MariaDB, Redis, GitLab, Linux
Cybersecurity Awareness Dashboard | 2023 Developed a web dashboard and
backend system for cybersecurity awareness training. Tech: React, TypeScript, Django,
MariaDB, GitLab, Linux
Open Source & Portfolio
Go4lage High-performance web framework I built from scratch in Go. Production-ready
with Docker and PostgreSQL integration. https://go4lage.com
Go4lage Tools VS Code extension for seamless Go and TypeScript development
workflows. https://github.com/Karl1b/go4lagetools
NLP Solver Mathematical solver plugin for OnlyOffice. Go compiled to WebAssembly.
https://github.com/Karl1b/only-office-nlp-solver
AI Email Responder Automated email response system with IMAP synchronization.
https://www.youtube.com/watch?v=sm1j6QjbP5Q
GeminiCV AI-powered resume optimization tool. https://geminicv.karlbreuer.com |
https://www.youtube.com/watch?v=jHNNeVSqJMI
Previous Engineering Roles
Project Manager | KSB SE & Co. KGaA, Halle | Nov 2018 Apr 2022 Led technical
consulting for national and international clients. Managed complex projects and drove
process optimization initiatives.
Mechanical Component Engineer | Siemens AG, Görlitz (via Brunel) | Sep 2017 Jun
2018 Designed and specified turbine components. Provided technical consulting for
engineering teams.
Process Engineer | RVT Process Equipment, Steinwiesen | Aug 2015 May 2017
Planned flue gas treatment systems. Performed hydraulic calculations and process
optimization.
Education
Diplom-Ingenieur (M.Sc. equivalent), Food Technology TU Berlin | 2008 2015
Specialization: Process Engineering
Patent
Heat Exchanger with Phase Change Storage (WO 2020/151850) Energy storage solution
for renewable energy applications, including wind power.
Languages
German (Native) | English (Fluent)
Additional Skills
Project estimation & budgeting, client presentations, technical mentoring, patent
development
Available for remote work and on-site projects in Germany/EU
Halle (Saale), March 18, 2026`
+221
View File
@@ -0,0 +1,221 @@
package audiocap
import (
"context"
"errors"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/option"
)
const (
Reset = "\033[0m"
Dim = "\033[2m"
Red = "\033[31m"
Green = "\033[32m"
Yellow = "\033[33m"
Cyan = "\033[36m"
)
type Responder struct {
tts *Client
BufferedResponse string
mu sync.Mutex
running bool
genaiClient *genai.Client
model *genai.GenerativeModel
}
func NewResponder(ctx context.Context, ttsClient *Client) (*Responder, error) {
apiKey := os.Getenv("GEMINI_API_KEY")
if apiKey == "" {
return nil, errors.New("GEMINI_API_KEY not set")
}
client, err := genai.NewClient(ctx, option.WithAPIKey(apiKey))
if err != nil {
return nil, fmt.Errorf("genai client: %w", err)
}
model := client.GenerativeModel("gemini-2.5-flash")
model.SetTemperature(0.43)
model.SystemInstruction = &genai.Content{
Parts: []genai.Part{genai.Text(fmt.Sprintf(sysPrompt, userCV))},
}
model.SafetySettings = []*genai.SafetySetting{
{Category: genai.HarmCategoryHarassment, Threshold: genai.HarmBlockNone},
{Category: genai.HarmCategoryHateSpeech, Threshold: genai.HarmBlockNone},
{Category: genai.HarmCategorySexuallyExplicit, Threshold: genai.HarmBlockNone},
{Category: genai.HarmCategoryDangerousContent, Threshold: genai.HarmBlockNone},
}
return &Responder{
tts: ttsClient,
genaiClient: client,
model: model,
}, nil
}
func (r *Responder) ResponseDaemon(ctx context.Context) {
ticker := time.NewTicker(2 * time.Second)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
puffMsg := r.Get() // In case a final text was transmitted during speak. 2s is fine.
if puffMsg != "" {
r.GetResponse("")
}
}
}
}
const sysPrompt = `You are a user at an interview, give one short suitable and good response that fit into a job interview.
Respond directly with the answer just if you are spoken directly to.
This is YOUR CV:
%s`
func (r *Responder) Set(message string) {
r.mu.Lock()
r.BufferedResponse = r.BufferedResponse + message
r.mu.Unlock()
}
func (r *Responder) GetAndClear() string {
r.mu.Lock()
res := r.BufferedResponse
r.BufferedResponse = ""
r.mu.Unlock()
return res
}
func (r *Responder) Get() string {
r.mu.Lock()
res := r.BufferedResponse
r.mu.Unlock()
return res
}
func (r *Responder) Clear() {
r.mu.Lock()
r.BufferedResponse = ""
r.mu.Unlock()
}
type HistoryMessage struct {
Role string
Message string
}
type HistoryT struct {
History []HistoryMessage
my sync.Mutex
}
var History HistoryT
func (h *HistoryT) Set(role, message string) {
h.my.Lock()
h.History = append(h.History, HistoryMessage{
Role: role,
Message: message,
})
h.my.Unlock()
}
func (h *HistoryT) GetLastN(n int) string {
h.my.Lock()
defer h.my.Unlock()
start := 0
if len(h.History) > n {
start = len(h.History) - n
}
var sb strings.Builder
for _, m := range h.History[start:] {
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Message)
}
return sb.String()
}
func buildPrompt(question string) string {
return fmt.Sprintf(`Last 5 messages:
%s
CURRENT QUESTION: %s`, History.GetLastN(5), question)
}
func (r *Responder) GetResponse(question string) {
go func() {
if question != "" {
r.Set(question + " ")
}
r.mu.Lock()
if r.running {
r.mu.Unlock()
return
}
r.running = true
r.mu.Unlock()
finalQuestion := r.GetAndClear()
prompt := buildPrompt(finalQuestion) // Wenn sie laufen soll, den Buffer leeren.
res, err := r.callGemini(prompt)
if err != nil {
log.Printf("❌ gemini: %v", err)
r.mu.Lock()
r.running = false
r.mu.Unlock()
return
}
fmt.Printf("%s🤖 %s%s\n", Green, res, Reset)
History.Set("user", finalQuestion)
History.Set("model", res)
if r.tts != nil {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
fmt.Printf("%s🤖 %s%s\n", Cyan, "Speak Start!", Reset)
if err := r.tts.Speak(ctx, res); err != nil {
log.Printf("%s❌ tts: %v%s", Red, err, Reset)
} else {
fmt.Printf("%s🤖 %s%s\n", Cyan, "Speak End!", Reset)
}
}
r.mu.Lock()
r.running = false
r.mu.Unlock()
}()
}
func (r *Responder) callGemini(prompt string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resp, err := r.model.GenerateContent(ctx, genai.Text(prompt))
if err != nil {
return "", fmt.Errorf("generate: %w", err)
}
if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
return "", errors.New("no content")
}
text, ok := resp.Candidates[0].Content.Parts[0].(genai.Text)
if !ok {
return "", errors.New("unexpected content type")
}
return string(text), nil
}
+67
View File
@@ -0,0 +1,67 @@
package audiocap
import (
"context"
"fmt"
"os/exec"
texttospeech "cloud.google.com/go/texttospeech/apiv1"
"cloud.google.com/go/texttospeech/apiv1/texttospeechpb"
)
type Client struct {
client *texttospeech.Client
sink string // pulse sink to play into
}
func New(ctx context.Context, sink string) (*Client, error) {
c, err := texttospeech.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("tts client: %w", err)
}
return &Client{client: c, sink: sink}, nil
}
func (c *Client) Close() error {
return c.client.Close()
}
// Speak synthesizes text and plays it into the configured sink.
func (c *Client) Speak(ctx context.Context, text string) error {
req := &texttospeechpb.SynthesizeSpeechRequest{
Input: &texttospeechpb.SynthesisInput{
InputSource: &texttospeechpb.SynthesisInput_Text{Text: text},
},
Voice: &texttospeechpb.VoiceSelectionParams{
LanguageCode: "en-US",
Name: "en-US-Neural2-D",
},
AudioConfig: &texttospeechpb.AudioConfig{
AudioEncoding: texttospeechpb.AudioEncoding_LINEAR16,
SampleRateHertz: 24000,
},
}
resp, err := c.client.SynthesizeSpeech(ctx, req)
if err != nil {
return fmt.Errorf("synthesize: %w", err)
}
// LINEAR16 from Google TTS is raw PCM wrapped in a WAV header.
// paplay handles WAV directly.
cmd := exec.CommandContext(ctx, "paplay",
"--device="+c.sink,
)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("paplay: %w", err)
}
if _, err := stdin.Write(resp.AudioContent); err != nil {
return fmt.Errorf("write: %w", err)
}
stdin.Close()
return cmd.Wait()
}
+141
View File
@@ -0,0 +1,141 @@
package audiocap
import (
"fmt"
"os/exec"
"strings"
)
const (
SinkName = "cheater_mic"
SourceName = "CheaterMic"
RecordingSink = "recording"
)
type Handle struct {
// virtmic modules
sinkModuleID string
sourceModuleID string
// recording modules
recordingSinkID string
callLoopbackID string
ttsLoopbackID string
}
func Setup() (*Handle, error) {
h := &Handle{}
// 1. virtmic null-sink
sinkID, err := pactlLoad("module-null-sink",
"sink_name="+SinkName,
"sink_properties=device.description=CheaterSink",
)
if err != nil {
return nil, fmt.Errorf("null-sink: %w", err)
}
h.sinkModuleID = sinkID
// 2. virtmic remap-source
sourceID, err := pactlLoad("module-remap-source",
"source_name="+SourceName,
"master="+SinkName+".monitor",
"source_properties=device.description=CheaterMic",
)
if err != nil {
h.Teardown()
return nil, fmt.Errorf("remap-source: %w", err)
}
h.sourceModuleID = sourceID
// 3. recording null-sink
recID, err := pactlLoad("module-null-sink",
"sink_name="+RecordingSink,
"sink_properties=device.description=Recording",
)
if err != nil {
h.Teardown()
return nil, fmt.Errorf("recording sink: %w", err)
}
h.recordingSinkID = recID
// 4. Call-audio loopback: resolve default sink monitor explicitly
defaultMonitor, err := defaultSinkMonitor()
if err != nil {
h.Teardown()
return nil, fmt.Errorf("resolve default monitor: %w", err)
}
callID, err := pactlLoad("module-loopback",
"source="+defaultMonitor,
"sink="+RecordingSink,
"latency_msec=20",
)
if err != nil {
h.Teardown()
return nil, fmt.Errorf("call loopback: %w", err)
}
h.callLoopbackID = callID
// 5. TTS loopback: cheater_mic monitor → recording
ttsID, err := pactlLoad("module-loopback",
"source="+SinkName+".monitor",
"sink="+RecordingSink,
"latency_msec=20",
)
if err != nil {
h.Teardown()
return nil, fmt.Errorf("tts loopback: %w", err)
}
h.ttsLoopbackID = ttsID
return h, nil
}
// Teardown unloads in reverse order. Safe to call even after partial Setup.
func (h *Handle) Teardown() {
if h.ttsLoopbackID != "" {
_ = pactlUnload(h.ttsLoopbackID)
}
if h.callLoopbackID != "" {
_ = pactlUnload(h.callLoopbackID)
}
if h.recordingSinkID != "" {
_ = pactlUnload(h.recordingSinkID)
}
if h.sourceModuleID != "" {
_ = pactlUnload(h.sourceModuleID)
}
if h.sinkModuleID != "" {
_ = pactlUnload(h.sinkModuleID)
}
}
func (h *Handle) SinkForPlayback() string {
return SinkName
}
// defaultSinkMonitor returns "<default-sink-name>.monitor"
func defaultSinkMonitor() (string, error) {
out, err := exec.Command("pactl", "get-default-sink").Output()
if err != nil {
return "", err
}
sink := strings.TrimSpace(string(out))
if sink == "" {
return "", fmt.Errorf("empty default sink")
}
return sink + ".monitor", nil
}
func pactlLoad(module string, args ...string) (string, error) {
cmdArgs := append([]string{"load-module", module}, args...)
out, err := exec.Command("pactl", cmdArgs...).Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func pactlUnload(id string) error {
return exec.Command("pactl", "unload-module", id).Run()
}