From 0f0c43f32701c314e2472ef1f9a1ec8a68ab0d1a Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sat, 7 Mar 2026 16:24:39 +0300 Subject: [PATCH] Dep: remove beep/portaudio dependancy --- extra/google_tts.go | 211 ++++++++++++++++++++++++++++++++++++++++++++ extra/kokoro.go | 42 +++------ extra/tts.go | 190 --------------------------------------- go.mod | 7 +- go.sum | 13 +-- 5 files changed, 226 insertions(+), 237 deletions(-) create mode 100644 extra/google_tts.go diff --git a/extra/google_tts.go b/extra/google_tts.go new file mode 100644 index 0000000..5b46f34 --- /dev/null +++ b/extra/google_tts.go @@ -0,0 +1,211 @@ +//go:build extra +// +build extra + +package extra + +import ( + "fmt" + "gf-lt/models" + "io" + "log/slog" + "os/exec" + "strings" + "sync" + + google_translate_tts "github.com/GrailFinder/google-translate-tts" + "github.com/neurosnap/sentences/english" +) + +type GoogleTranslateOrator struct { + logger *slog.Logger + mu sync.Mutex + speech *google_translate_tts.Speech + // fields for playback control + cmd *exec.Cmd + cmdMu sync.Mutex + stopCh chan struct{} + // text buffer and interrupt flag + textBuffer strings.Builder + interrupt bool +} + +func (o *GoogleTranslateOrator) stoproutine() { + for { + <-TTSDoneChan + o.logger.Debug("orator got done signal") + o.Stop() + for len(TTSTextChan) > 0 { + <-TTSTextChan + } + o.mu.Lock() + o.textBuffer.Reset() + o.interrupt = true + o.mu.Unlock() + } +} + +func (o *GoogleTranslateOrator) readroutine() { + tokenizer, _ := english.NewSentenceTokenizer(nil) + for { + select { + case chunk := <-TTSTextChan: + o.mu.Lock() + o.interrupt = false + _, err := o.textBuffer.WriteString(chunk) + if err != nil { + o.logger.Warn("failed to write to stringbuilder", "error", err) + o.mu.Unlock() + continue + } + text := o.textBuffer.String() + sentences := tokenizer.Tokenize(text) + o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences)) + if len(sentences) <= 1 { + o.mu.Unlock() + continue + } + completeSentences := sentences[:len(sentences)-1] + remaining := sentences[len(sentences)-1].Text + o.textBuffer.Reset() + o.textBuffer.WriteString(remaining) + o.mu.Unlock() + for _, sentence := range completeSentences { + o.mu.Lock() + interrupted := o.interrupt + o.mu.Unlock() + if interrupted { + return + } + cleanedText := models.CleanText(sentence.Text) + if cleanedText == "" { + continue + } + o.logger.Debug("calling Speak with sentence", "sent", cleanedText) + if err := o.Speak(cleanedText); err != nil { + o.logger.Error("tts failed", "sentence", cleanedText, "error", err) + } + } + case <-TTSFlushChan: + o.logger.Debug("got flushchan signal start") + // lln is done get the whole message out + if len(TTSTextChan) > 0 { // otherwise might get stuck + for chunk := range TTSTextChan { + o.mu.Lock() + _, err := o.textBuffer.WriteString(chunk) + o.mu.Unlock() + if err != nil { + o.logger.Warn("failed to write to stringbuilder", "error", err) + continue + } + if len(TTSTextChan) == 0 { + break + } + } + } + o.mu.Lock() + remaining := o.textBuffer.String() + remaining = models.CleanText(remaining) + o.textBuffer.Reset() + o.mu.Unlock() + if remaining == "" { + continue + } + o.logger.Debug("calling Speak with remainder", "rem", remaining) + sentencesRem := tokenizer.Tokenize(remaining) + for _, rs := range sentencesRem { // to avoid dumping large volume of text + o.mu.Lock() + interrupt := o.interrupt + o.mu.Unlock() + if interrupt { + break + } + if err := o.Speak(rs.Text); err != nil { + o.logger.Error("tts failed", "sentence", rs.Text, "error", err) + } + } + } + } +} + +func (o *GoogleTranslateOrator) GetLogger() *slog.Logger { + return o.logger +} + +func (o *GoogleTranslateOrator) Speak(text string) error { + o.logger.Debug("fn: Speak is called", "text-len", len(text)) + // Generate MP3 data directly as an io.Reader + reader, err := o.speech.GenerateSpeech(text) + if err != nil { + return fmt.Errorf("generate speech failed: %w", err) + } + // Wrap in io.NopCloser since GenerateSpeech returns io.Reader (no close needed) + body := io.NopCloser(reader) + defer body.Close() + // Exactly the same ffplay piping as KokoroOrator + cmd := exec.Command("ffplay", "-nodisp", "-autoexit", "-i", "pipe:0") + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to get stdin pipe: %w", err) + } + o.cmdMu.Lock() + o.cmd = cmd + o.stopCh = make(chan struct{}) + o.cmdMu.Unlock() + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start ffplay: %w", err) + } + copyErr := make(chan error, 1) + go func() { + _, err := io.Copy(stdin, body) + stdin.Close() + copyErr <- err + }() + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + select { + case <-o.stopCh: + if o.cmd != nil && o.cmd.Process != nil { + o.cmd.Process.Kill() + } + <-done + return nil + case copyErrVal := <-copyErr: + if copyErrVal != nil { + if o.cmd != nil && o.cmd.Process != nil { + o.cmd.Process.Kill() + } + <-done + return copyErrVal + } + return <-done + case err := <-done: + return err + } +} + +func (o *GoogleTranslateOrator) Stop() { + o.cmdMu.Lock() + defer o.cmdMu.Unlock() + // Signal any running Speak to stop + if o.stopCh != nil { + select { + case <-o.stopCh: // already closed + default: + close(o.stopCh) + } + o.stopCh = nil + } + // Kill the external player process if it's still running + if o.cmd != nil && o.cmd.Process != nil { + o.cmd.Process.Kill() + o.cmd.Wait() // clean up zombie process + o.cmd = nil + } + // Also reset text buffer and interrupt flag (with o.mu) + o.mu.Lock() + o.textBuffer.Reset() + o.interrupt = true + o.mu.Unlock() +} diff --git a/extra/kokoro.go b/extra/kokoro.go index 15b173b..e3ca047 100644 --- a/extra/kokoro.go +++ b/extra/kokoro.go @@ -40,17 +40,13 @@ func (o *KokoroOrator) GetLogger() *slog.Logger { return o.logger } -// Speak streams audio directly to an external player func (o *KokoroOrator) Speak(text string) error { o.logger.Debug("fn: Speak is called", "text-len", len(text)) - // 1. Get the audio stream (still an io.ReadCloser) body, err := o.requestSound(text) if err != nil { return fmt.Errorf("request failed: %w", err) } defer body.Close() - // 2. Prepare external player (ffplay as example) - // -i pipe:0 tells ffplay to read from stdin cmd := exec.Command("ffplay", "-nodisp", "-autoexit", "-i", "pipe:0") stdin, err := cmd.StdinPipe() if err != nil { @@ -60,60 +56,46 @@ func (o *KokoroOrator) Speak(text string) error { o.cmd = cmd o.stopCh = make(chan struct{}) o.cmdMu.Unlock() - // 3. Start the player if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start ffplay: %w", err) } - // 4. Copy audio data to stdin in a goroutine + // Copy audio in background copyErr := make(chan error, 1) go func() { _, err := io.Copy(stdin, body) - stdin.Close() // signal EOF to player + stdin.Close() copyErr <- err }() - // 5. Wait for player to finish or stop signal + // Wait for player in background done := make(chan error, 1) go func() { done <- cmd.Wait() }() + // Wait for BOTH copy and player, but ensure we block until done select { case <-o.stopCh: - // Stop requested: kill the player + // Stop requested: kill player and wait for it to exit if o.cmd != nil && o.cmd.Process != nil { o.cmd.Process.Kill() } - <-done // wait for process to exit + <-done // Wait for process to actually exit return nil - case err := <-done: - // Playback finished normally - return err case copyErrVal := <-copyErr: if copyErrVal != nil { - // Copy failed – kill the player + // Copy failed: kill player and wait if o.cmd != nil && o.cmd.Process != nil { o.cmd.Process.Kill() } <-done return copyErrVal } - return nil + // Copy succeeded, now wait for playback to complete + return <-done + case err := <-done: + // Playback finished normally (copy must have succeeded or player would have exited early) + return err } } - -// // Stop interrupts ongoing playback -// func (o *KokoroOrator) Stop() { -// o.cmdMu.Lock() -// defer o.cmdMu.Unlock() -// if o.stopCh != nil { -// close(o.stopCh) -// } -// // Also clear the buffer and set interrupt flag as before -// o.mu.Lock() -// o.textBuffer.Reset() -// o.interrupt = true -// o.mu.Unlock() -// } - func (o *KokoroOrator) requestSound(text string) (io.ReadCloser, error) { if o.URL == "" { return nil, fmt.Errorf("TTS URL is empty") diff --git a/extra/tts.go b/extra/tts.go index a75678b..80085ab 100644 --- a/extra/tts.go +++ b/extra/tts.go @@ -4,22 +4,13 @@ package extra import ( - "fmt" "gf-lt/config" "gf-lt/models" - "io" "log/slog" "os" "strings" - "sync" - "time" google_translate_tts "github.com/GrailFinder/google-translate-tts" - "github.com/GrailFinder/google-translate-tts/handlers" - "github.com/gopxl/beep/v2" - "github.com/gopxl/beep/v2/mp3" - "github.com/gopxl/beep/v2/speaker" - "github.com/neurosnap/sentences/english" ) var ( @@ -36,17 +27,6 @@ type Orator interface { GetLogger() *slog.Logger } -// Google Translate TTS implementation -type GoogleTranslateOrator struct { - logger *slog.Logger - mu sync.Mutex - speech *google_translate_tts.Speech - currentStream *beep.Ctrl - currentDone chan bool - textBuffer strings.Builder - interrupt bool -} - func NewOrator(log *slog.Logger, cfg *config.Config) Orator { provider := cfg.TTS_PROVIDER if provider == "" { @@ -76,7 +56,6 @@ func NewOrator(log *slog.Logger, cfg *config.Config) Orator { Language: language, Proxy: "", // Proxy not supported Speed: cfg.TTS_SPEED, - Handler: &handlers.Beep{}, } orator := &GoogleTranslateOrator{ logger: log, @@ -87,172 +66,3 @@ func NewOrator(log *slog.Logger, cfg *config.Config) Orator { return orator } } - -func (o *GoogleTranslateOrator) stoproutine() { - for { - <-TTSDoneChan - o.logger.Debug("orator got done signal") - o.Stop() - // drain the channel - for len(TTSTextChan) > 0 { - <-TTSTextChan - } - o.mu.Lock() - o.textBuffer.Reset() - if o.currentDone != nil { - select { - case o.currentDone <- true: - default: - // Channel might be closed, ignore - } - } - o.interrupt = true - o.mu.Unlock() - } -} - -func (o *GoogleTranslateOrator) readroutine() { - tokenizer, _ := english.NewSentenceTokenizer(nil) - for { - select { - case chunk := <-TTSTextChan: - o.mu.Lock() - o.interrupt = false - _, err := o.textBuffer.WriteString(chunk) - if err != nil { - o.logger.Warn("failed to write to stringbuilder", "error", err) - o.mu.Unlock() - continue - } - text := o.textBuffer.String() - sentences := tokenizer.Tokenize(text) - o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences)) - if len(sentences) <= 1 { - o.mu.Unlock() - continue - } - completeSentences := sentences[:len(sentences)-1] - remaining := sentences[len(sentences)-1].Text - o.textBuffer.Reset() - o.textBuffer.WriteString(remaining) - o.mu.Unlock() - - for _, sentence := range completeSentences { - o.mu.Lock() - interrupted := o.interrupt - o.mu.Unlock() - if interrupted { - return - } - cleanedText := models.CleanText(sentence.Text) - if cleanedText == "" { - continue - } - o.logger.Debug("calling Speak with sentence", "sent", cleanedText) - if err := o.Speak(cleanedText); err != nil { - o.logger.Error("tts failed", "sentence", cleanedText, "error", err) - } - } - case <-TTSFlushChan: - o.logger.Debug("got flushchan signal start") - // lln is done get the whole message out - if len(TTSTextChan) > 0 { // otherwise might get stuck - for chunk := range TTSTextChan { - o.mu.Lock() - _, err := o.textBuffer.WriteString(chunk) - o.mu.Unlock() - if err != nil { - o.logger.Warn("failed to write to stringbuilder", "error", err) - continue - } - if len(TTSTextChan) == 0 { - break - } - } - } - o.mu.Lock() - remaining := o.textBuffer.String() - remaining = models.CleanText(remaining) - o.textBuffer.Reset() - o.mu.Unlock() - if remaining == "" { - continue - } - o.logger.Debug("calling Speak with remainder", "rem", remaining) - sentencesRem := tokenizer.Tokenize(remaining) - for _, rs := range sentencesRem { // to avoid dumping large volume of text - o.mu.Lock() - interrupt := o.interrupt - o.mu.Unlock() - if interrupt { - break - } - if err := o.Speak(rs.Text); err != nil { - o.logger.Error("tts failed", "sentence", rs.Text, "error", err) - } - } - } - } -} - -func (o *GoogleTranslateOrator) GetLogger() *slog.Logger { - return o.logger -} - -func (o *GoogleTranslateOrator) Speak(text string) error { - o.logger.Debug("fn: Speak is called", "text-len", len(text)) - // Generate MP3 data using google-translate-tts - reader, err := o.speech.GenerateSpeech(text) - if err != nil { - o.logger.Error("generate speech failed", "error", err) - return fmt.Errorf("generate speech failed: %w", err) - } - // Decode the mp3 audio from reader (wrap with NopCloser for io.ReadCloser) - streamer, format, err := mp3.Decode(io.NopCloser(reader)) - if err != nil { - o.logger.Error("mp3 decode failed", "error", err) - return fmt.Errorf("mp3 decode failed: %w", err) - } - defer streamer.Close() - playbackStreamer := beep.Streamer(streamer) - speed := o.speech.Speed - if speed <= 0 { - speed = 1.0 - } - if speed != 1.0 { - playbackStreamer = beep.ResampleRatio(3, float64(speed), streamer) - } - // Initialize speaker with the format's sample rate - if err := speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)); err != nil { - o.logger.Debug("failed to init speaker", "error", err) - } - done := make(chan bool) - o.mu.Lock() - o.currentDone = done - o.currentStream = &beep.Ctrl{Streamer: beep.Seq(playbackStreamer, beep.Callback(func() { - o.mu.Lock() - close(done) - o.currentStream = nil - o.currentDone = nil - o.mu.Unlock() - })), Paused: false} - o.mu.Unlock() - speaker.Play(o.currentStream) - <-done // wait for playback to complete - return nil -} - -func (o *GoogleTranslateOrator) Stop() { - o.logger.Debug("attempted to stop google translate orator") - speaker.Lock() - defer speaker.Unlock() - o.mu.Lock() - defer o.mu.Unlock() - if o.currentStream != nil { - o.currentStream.Streamer = nil - } - // Also stop the speech handler if possible - if o.speech != nil { - _ = o.speech.Stop() - } -} diff --git a/go.mod b/go.mod index 531609a..17609a4 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,11 @@ go 1.25.1 require ( github.com/BurntSushi/toml v1.5.0 - github.com/GrailFinder/google-translate-tts v0.1.3 + github.com/GrailFinder/google-translate-tts v0.1.4 github.com/GrailFinder/searchagent v0.2.0 github.com/PuerkitoBio/goquery v1.11.0 github.com/gdamore/tcell/v2 v2.13.2 github.com/glebarez/go-sqlite v1.22.0 - github.com/gopxl/beep/v2 v2.1.1 github.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b github.com/jmoiron/sqlx v1.4.0 github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 @@ -25,21 +24,17 @@ require ( github.com/andybalholm/cascadia v1.3.3 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/oto/v3 v3.4.0 // indirect - github.com/ebitengine/purego v0.9.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hajimehoshi/go-mp3 v0.3.4 // indirect - github.com/hajimehoshi/oto/v2 v2.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/schollz/progressbar/v2 v2.15.0 // indirect diff --git a/go.sum b/go.sum index 73d273b..565947e 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GrailFinder/google-translate-tts v0.1.3 h1:Mww9tNzTWjjSh+OCbTPl/+21oMPKcUecXZfU7nTB/lA= -github.com/GrailFinder/google-translate-tts v0.1.3/go.mod h1:YIOLKR7sObazdUCrSex3u9OVBovU55eYgWa25vsQJ18= +github.com/GrailFinder/google-translate-tts v0.1.4 h1:NJoPZUGfBrmouQMN19MUcNPNUx4tmf4a8OZRME4E4Mg= +github.com/GrailFinder/google-translate-tts v0.1.4/go.mod h1:YIOLKR7sObazdUCrSex3u9OVBovU55eYgWa25vsQJ18= github.com/GrailFinder/searchagent v0.2.0 h1:U2GVjLh/9xZt0xX9OcYk9Q2fMkyzyTiADPUmUisRdtQ= github.com/GrailFinder/searchagent v0.2.0/go.mod h1:d66tn5+22LI8IGJREUsRBT60P0sFdgQgvQRqyvgItrs= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= @@ -17,10 +17,6 @@ github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+Zlfu github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ= -github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= @@ -41,13 +37,10 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU= -github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E= github.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b h1:WEuQWBxelOGHA6z9lABqaMLMrfwVyMdN3UgRLT+YUPo= github.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b/go.mod h1:esZFQEUwqC+l76f2R8bIWSwXMaPbp79PppwZ1eJhFco= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= -github.com/hajimehoshi/oto/v2 v2.3.1 h1:qrLKpNus2UfD674oxckKjNJmesp9hMh7u7QCrStB3Rc= github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= @@ -71,8 +64,6 @@ github.com/neurosnap/sentences v1.1.2 h1:iphYOzx/XckXeBiLIUBkPu2EKMJ+6jDbz/sLJZ7 github.com/neurosnap/sentences v1.1.2/go.mod h1:/pwU4E9XNL21ygMIkOIllv/SMy2ujHwpf8GQPu1YPbQ= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/playwright-community/playwright-go v0.5700.1 h1:PNFb1byWqrTT720rEO0JL88C6Ju0EmUnR5deFLvtP/U= github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszbCxY6x3qK89ZesJUYVx21B2JnkoNF0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=