initial
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user