diff --git a/brclient/appstate.go b/brclient/appstate.go index 28cd8540..3858622d 100644 --- a/brclient/appstate.go +++ b/brclient/appstate.go @@ -36,6 +36,7 @@ import ( "github.com/companyzero/bisonrelay/client/resources/simplestore" "github.com/companyzero/bisonrelay/client/rpcserver" "github.com/companyzero/bisonrelay/clientrpc/types" + "github.com/companyzero/bisonrelay/internal/audio" "github.com/companyzero/bisonrelay/internal/mdembeds" "github.com/companyzero/bisonrelay/internal/strescape" "github.com/companyzero/bisonrelay/internal/tlsconn" @@ -220,6 +221,8 @@ type appState struct { ssPayType simpleStorePayType ssAcct string ssShipCharge float64 + + noterec *audio.NoteRecorder } type appStateErr struct { @@ -3946,6 +3949,11 @@ func newAppState(sendMsg func(tea.Msg), lndLogLines *sloglinesbuffer.Buffer, }) go r.Run(ctx) + noterec, err := audio.NewRecorder(logBknd.logger("AREC")) + if err != nil { + return nil, fmt.Errorf("unable to init audio subsystem: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) as = &appState{ ctx: ctx, @@ -3963,6 +3971,7 @@ func newAppState(sendMsg func(tea.Msg), lndLogLines *sloglinesbuffer.Buffer, lnRouter: lnRouter, httpClient: &httpClient, rates: r, + noterec: noterec, network: args.Network, isRestore: isRestore, diff --git a/brclient/commands.go b/brclient/commands.go index aa63036b..b3c43036 100644 --- a/brclient/commands.go +++ b/brclient/commands.go @@ -18,6 +18,7 @@ import ( "github.com/companyzero/bisonrelay/client" "github.com/companyzero/bisonrelay/client/clientdb" "github.com/companyzero/bisonrelay/client/clientintf" + "github.com/companyzero/bisonrelay/internal/audio" "github.com/companyzero/bisonrelay/internal/strescape" "github.com/companyzero/bisonrelay/zkidentity" "github.com/decred/dcrd/dcrutil/v4" @@ -25,6 +26,7 @@ import ( "github.com/decred/dcrlnd/lnrpc/routerrpc" "github.com/decred/dcrlnd/lnrpc/walletrpc" "github.com/decred/dcrlnd/lnwire" + "github.com/gen2brain/malgo" "github.com/mitchellh/go-homedir" "github.com/skip2/go-qrcode" "golang.org/x/exp/slices" @@ -3549,6 +3551,183 @@ var myAvatarCmds = []tuicmd{ }, } +var audioCmds = []tuicmd{ + { + cmd: "devices", + aliases: []string{"listdevices"}, + descr: "List capture and playback devices", + usableOffline: true, + handler: func(args []string, as *appState) error { + devices, err := audio.ListAudioDevices(as.log) + if err != nil { + return err + } + + as.manyDiagMsgsCb(func(pf printf) { + printDevice := func(i int, _ malgo.DeviceType, dev *audio.Device) { + defaultStr := "" + if dev.IsDefault { + defaultStr = "(default) " + } + pf("Device %d %s%s", i, defaultStr, dev.Name) + pf("ID: %s", strescape.Nick(dev.ID)) + pf("") + } + + pf("") + if len(devices.Capture) == 0 { + pf("No audio capture devices found") + } else { + pf("Audio capture devices") + pf("") + for i := range devices.Capture { + printDevice(i, malgo.Capture, &devices.Capture[i]) + } + } + + if len(devices.Playback) == 0 { + pf("No audio playback devices found") + } else { + pf("Audio playback devices") + pf("") + for i := range devices.Playback { + printDevice(i, malgo.Playback, &devices.Playback[i]) + } + } + }) + + return nil + }, + }, { + cmd: "capturedevice", + usableOffline: true, + aliases: []string{"capdevice", "cdevice", "capdev"}, + usage: "Select the capture device", + descr: "[]", + handler: func(args []string, as *appState) error { + if len(args) == 0 { + as.noterec.SetCaptureDevice(nil) + as.diagMsg("Using default device for audio capture") + return nil + } + + devIndex, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return usageError{msg: "Argument not a number"} + } + if devIndex < 0 { + return usageError{msg: "Device index cannot be negative"} + } + + devices, err := audio.ListAudioDevices(as.log) + if err != nil { + return err + } + if devIndex >= int64(len(devices.Capture)) { + return fmt.Errorf("device %d does not exist", devIndex) + } + + err = as.noterec.SetCaptureDevice(&devices.Capture[devIndex]) + return err + }, + }, { + cmd: "playbackdevice", + usableOffline: true, + aliases: []string{"playdevice", "pdevice", "playdev"}, + usage: "Select the playback device", + descr: "[]", + handler: func(args []string, as *appState) error { + if len(args) == 0 { + as.noterec.SetPlaybackDevice(nil) + as.diagMsg("Using default device for audio capture") + return nil + } + + devIndex, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return usageError{msg: "Argument not a number"} + } + if devIndex < 0 { + return usageError{msg: "Device index cannot be negative"} + } + + devices, err := audio.ListAudioDevices(as.log) + if err != nil { + return err + } + if devIndex >= int64(len(devices.Playback)) { + return fmt.Errorf("device %d does not exist", devIndex) + } + + err = as.noterec.SetPlaybackDevice(&devices.Playback[devIndex]) + return err + }, + }, { + cmd: "send", + descr: "Send an audio note", + usage: "[]", + handler: func(args []string, as *appState) error { + var targetId clientintf.UserID + var targetIsGC bool + + if len(args) > 0 { + uid, err := as.c.UIDByNick(args[0]) + if err == nil { + targetId = uid + } else if gcid, err := as.c.GCIDByName(args[0]); err == nil { + targetId = gcid + targetIsGC = true + } else { + return usageError{"Target user or GC not found"} + } + } else { + cw := as.activeChatWindow() + if cw == nil || cw.isPage { + return usageError{"No target specified"} + } + if cw.isGC { + targetId = cw.gc + targetIsGC = true + } else { + targetId = cw.uid + } + } + + as.sendMsg(msgSendAudioNote{targetID: targetId, targetIsGC: targetIsGC}) + return nil + }, + completer: func(args []string, arg string, as *appState) []string { + if len(args) == 0 { + return nickCompleter(arg, as) + } + return nil + }, + }, { + cmd: "test", + usableOffline: true, + descr: "Record and playback a 3-second test note", + handler: func(args []string, as *appState) error { + go func() { + as.diagMsg("Starting 3 second capture") + ctx, cancel := context.WithTimeout(as.ctx, 3*time.Second) + defer cancel() + err := as.noterec.Capture(ctx) + if err != nil { + as.diagMsg("Error capturing audio: %v", err) + return + } + + as.diagMsg("Starting playback") + err = as.noterec.Playback(as.ctx) + if err != nil { + as.diagMsg("Error playing back audio: %v", err) + } + }() + return nil + }, + }, +} + var commands = []tuicmd{ { cmd: "backup", @@ -4279,6 +4458,12 @@ var commands = []tuicmd{ as.cwHelpMsg("Cleared payment stats%s", forUser) return nil }, + completer: func(args []string, arg string, as *appState) []string { + if len(args) == 0 { + return nickCompleter(arg, as) + } + return nil + }, }, { cmd: "info", usableOffline: true, @@ -4452,6 +4637,18 @@ var commands = []tuicmd{ }() return nil }, + }, { + cmd: "audio", + usableOffline: true, + descr: "Audio-related commands", + sub: audioCmds, + completer: func(args []string, arg string, as *appState) []string { + if len(args) == 0 { + return cmdCompleter(audioCmds, arg, false) + } + return nil + }, + handler: subcmdNeededHandler, }, { cmd: "quit", usableOffline: true, diff --git a/brclient/mainwindow.go b/brclient/mainwindow.go index 8c773e4f..c848b40d 100644 --- a/brclient/mainwindow.go +++ b/brclient/mainwindow.go @@ -725,6 +725,9 @@ func (mws mainWindowState) Update(msg tea.Msg) (tea.Model, tea.Cmd) { mws.completeIdx = 0 } + case msgSendAudioNote: + return newSendAudioNoteWindow(mws.as, msg) + default: // Handle other messages. mws.textArea, cmd = mws.textArea.Update(msg) diff --git a/brclient/messages.go b/brclient/messages.go index d9c2c7e7..0e48a98f 100644 --- a/brclient/messages.go +++ b/brclient/messages.go @@ -85,6 +85,13 @@ type msgUnwelcomeError struct { type msgReplaceCmd string +type msgRecordNote struct{} +type msgPlaybackNote struct{} +type msgRefreshAudioNoteUI struct{} +type msgRecordComplete struct{} +type msgPlaybackComplete struct{} +type msgAudioError error + func paste() tea.Msg { str, err := clipboard.ReadAll() if err != nil { @@ -194,6 +201,13 @@ func emitMsg(msg tea.Msg) tea.Cmd { } } +func emitAfter(msg tea.Msg, delay time.Duration) tea.Cmd { + return func() tea.Msg { + time.Sleep(delay) + return msg + } +} + type msgRunCmd func() tea.Msg type msgExternalCommentResult struct { @@ -202,6 +216,11 @@ type msgExternalCommentResult struct { parent *zkidentity.ShortID } +type msgSendAudioNote struct { + targetID clientintf.UserID + targetIsGC bool +} + // isQuitMsg returns true if the app should quit as a response to the given // msg. It returns an error with the reason for quitting. func isQuitMsg(msg tea.Msg) error { diff --git a/brclient/sendaudionotewin.go b/brclient/sendaudionotewin.go new file mode 100644 index 00000000..1f85a084 --- /dev/null +++ b/brclient/sendaudionotewin.go @@ -0,0 +1,274 @@ +package main + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/companyzero/bisonrelay/client/clientintf" + "github.com/companyzero/bisonrelay/internal/mdembeds" + "github.com/decred/dcrd/dcrutil/v4" + "golang.org/x/net/context" +) + +type sendAudioNoteWin struct { + initless + + as *appState + target msgSendAudioNote + opusFile []byte + uploadCost dcrutil.Amount + embedArgs mdembeds.EmbeddedArgs + targetCount int + indicator spinner.Model + btns formHelper + err error +} + +func (w *sendAudioNoteWin) updateButtons() { + styles := w.as.styles.Load() + btns := newFormHelper(styles) + + recording, playing := w.as.noterec.Busy() + hasRecorded := w.as.noterec.HasRecorded() + + if recording || playing { + btns.AddInputs(newButtonHelper( + styles, + btnWithLabel("[ Stop ]"), + btnWithTrailing(" "), + btnWithFixedMsgAction(msgCancelForm{}), + )) + } else { + btns.AddInputs(newButtonHelper( + styles, + btnWithLabel("[ Record ]"), + btnWithTrailing(" "), + btnWithFixedMsgAction(msgRecordNote{}), + )) + + if hasRecorded { + btns.AddInputs(newButtonHelper( + styles, + btnWithLabel("[ Play ]"), + btnWithTrailing(" "), + btnWithFixedMsgAction(msgPlaybackNote{}), + )) + + btns.AddInputs(newButtonHelper( + styles, + btnWithLabel("[ Accept ]"), + btnWithTrailing(" "), + btnWithFixedMsgAction(msgSubmitForm{}), + )) + } + } + + w.btns = btns + w.btns.setFocus(0) +} + +func (w *sendAudioNoteWin) updateRecordData() error { + if !w.as.noterec.HasRecorded() { + return nil + } + + data, err := w.as.noterec.OpusFile() + if err != nil { + return err + } + + var args mdembeds.EmbeddedArgs + args.Alt = "Audio note" + args.Typ = "audio/ogg" + args.Filename = time.Now().Format("2006-01-02-15_04_05") + "-audionote.opus" + args.Data = data + msg := args.String() + + feeRate, _ := w.as.serverPaymentRates() + estCost, err := clientintf.EstimatePMCost(msg, feeRate) + if err != nil { + return err + } + + if w.target.targetIsGC { + w.targetCount = w.as.c.GetGCDestCount(w.target.targetID) + } else { + w.targetCount = 1 + } + + w.opusFile = data + w.uploadCost = dcrutil.Amount(1 + estCost*uint64(w.targetCount)/1000) + w.embedArgs = args + return nil +} + +func (w *sendAudioNoteWin) submitToTarget() error { + var cw *chatWindow + if w.target.targetIsGC { + cw = w.as.findOrNewGCWindow(w.target.targetID) + } else { + cw = w.as.findOrNewChatWindow(w.target.targetID, "") + } + + w.as.pm(cw, w.embedArgs.String()) + return nil +} + +func (w sendAudioNoteWin) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Early check for a quit msg to put us into the shutdown state (to + // shutdown DB, etc). + if ss, cmd := maybeShutdown(w.as, msg); ss != nil { + return ss, cmd + } + + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case msg.Type == tea.KeyEsc: + w.as.noterec.Stop() + return newMainWindowState(w.as) + + default: + w.btns, cmd = w.btns.Update(msg) + } + + case msgAudioError: + w.err = error(msg) + + case msgRecordNote: + go func() { + err := w.as.noterec.Capture(w.as.ctx) + if err != nil && !errors.Is(err, context.Canceled) { + w.as.sendMsg(msgAudioError(err)) + } else { + w.as.sendMsg(msgRecordComplete{}) + } + }() + cmd = batchCmds([]tea.Cmd{ + w.indicator.Tick, + emitAfter(msgRefreshAudioNoteUI{}, 20*time.Millisecond), + }) + + case msgPlaybackNote: + go func() { + err := w.as.noterec.Playback(w.as.ctx) + if err != nil && !errors.Is(err, context.Canceled) { + w.as.sendMsg(msgAudioError(err)) + } else { + w.as.sendMsg(msgPlaybackComplete{}) + } + + }() + cmd = batchCmds([]tea.Cmd{ + w.indicator.Tick, + emitAfter(msgRefreshAudioNoteUI{}, 20*time.Millisecond), + }) + + case msgCancelForm: + w.as.noterec.Stop() + w.err = nil + cmd = emitAfter(msgRefreshAudioNoteUI{}, 20*time.Millisecond) + + case msgSubmitForm: + w.err = w.submitToTarget() + if w.err == nil { + return newMainWindowState(w.as) + } + + case msgRecordComplete: + w.err = w.updateRecordData() + w.updateButtons() + if w.err == nil { + // Set focus on "play" button after recording. + w.btns.setFocus(1) + } + + case msgPlaybackComplete: + w.updateButtons() + w.btns.setFocus(1) + + case msgRefreshAudioNoteUI: + w.updateButtons() + + case spinner.TickMsg: + w.indicator, cmd = w.indicator.Update(msg) + } + + return w, cmd +} + +func (w sendAudioNoteWin) View() string { + b := new(strings.Builder) + + styles := w.as.styles.Load() + headerMsg := styles.header.Render(" Record and send audio note") + spaces := styles.header.Render(strings.Repeat(" ", + max(0, w.as.winW-lipgloss.Width(headerMsg)))) + b.WriteString(headerMsg + spaces) + b.WriteRune('\n') + + nbLines := 1 + + recording, playing := w.as.noterec.Busy() + if recording || playing { + msg := "Recording" + if playing { + msg = "Playing" + } + b.WriteString(msg + " ") + b.WriteString(w.indicator.View()) + b.WriteString("\n\n\n") + nbLines += 3 + } else { + hasRecorded := w.as.noterec.HasRecorded() + recInfo := w.as.noterec.RecordInfo() + + if hasRecorded { + recDuration := time.Millisecond * time.Duration(recInfo.DurationMs) + fmt.Fprintf(b, "Record size: %s (%s)\n", + hbytes(int64(recInfo.EncodedSize)), recDuration) + fmt.Fprintf(b, "Estimated cost: %s (%d %s)", + w.uploadCost, w.targetCount, + plural(w.targetCount, "target", "targets")) + } else { + b.WriteString("\n") + } + b.WriteString("\n\n") + nbLines += 3 + } + + b.WriteString(w.btns.View()) + b.WriteRune('\n') + nbLines += 1 + + if w.err != nil { + b.WriteRune('\n') + b.WriteString(styles.err.Render(w.err.Error())) + b.WriteRune('\n') + nbLines += 2 + } + + b.WriteString(blankLines(w.as.winH - nbLines - 2)) + b.WriteString(w.as.footerView(styles, "")) + + return b.String() +} + +func newSendAudioNoteWindow(as *appState, target msgSendAudioNote) (sendAudioNoteWin, tea.Cmd) { + indicator := spinner.New(spinner.WithSpinner(spinner.Points)) + w := sendAudioNoteWin{ + as: as, + target: target, + indicator: indicator, + } + w.updateRecordData() + w.updateButtons() + return w, nil +} diff --git a/brclient/util.go b/brclient/util.go index 83338378..097f99ca 100644 --- a/brclient/util.go +++ b/brclient/util.go @@ -251,6 +251,13 @@ func hbytes(i int64) string { } } +func plural(i int, s, p string) string { + if i == 1 { + return s + } + return p +} + func programByMimeType(mimeMap map[string]string, t string) string { f, exists := mimeMap[t] if exists {