diff --git a/apps/api-gql/cmd/main.go b/apps/api-gql/cmd/main.go index bfb58c1d8..18a0f4418 100644 --- a/apps/api-gql/cmd/main.go +++ b/apps/api-gql/cmd/main.go @@ -32,6 +32,7 @@ import ( "github.com/twirapp/twir/apps/api-gql/internal/services/roles_users" "github.com/twirapp/twir/apps/api-gql/internal/services/roles_with_roles_users" "github.com/twirapp/twir/apps/api-gql/internal/services/timers" + ttsvoices "github.com/twirapp/twir/apps/api-gql/internal/services/tts_voices" "github.com/twirapp/twir/apps/api-gql/internal/services/twir-users" "github.com/twirapp/twir/apps/api-gql/internal/services/twitch" "github.com/twirapp/twir/apps/api-gql/internal/services/users" @@ -130,6 +131,7 @@ func main() { roles_with_roles_users.New, twitch.New, channels.New, + ttsvoices.New, ), // repositories fx.Provide( diff --git a/apps/api-gql/gqlgen.yml b/apps/api-gql/gqlgen.yml index 3d1d7bf1f..eebb94987 100644 --- a/apps/api-gql/gqlgen.yml +++ b/apps/api-gql/gqlgen.yml @@ -91,3 +91,5 @@ models: UUID: model: - github.com/99designs/gqlgen/graphql.UUID + Bytes: + model: "github.com/twirapp/twir/apps/api-gql/internal/delivery/gql/types.Bytes" diff --git a/apps/api-gql/internal/delivery/gql/mappers/tts.go b/apps/api-gql/internal/delivery/gql/mappers/tts.go new file mode 100644 index 000000000..ab0045d43 --- /dev/null +++ b/apps/api-gql/internal/delivery/gql/mappers/tts.go @@ -0,0 +1,16 @@ +package mappers + +import ( + "github.com/twirapp/twir/apps/api-gql/internal/delivery/gql/gqlmodel" + "github.com/twirapp/twir/apps/api-gql/internal/entity" +) + +func RHVoiceTo(e entity.TTSRHVoice) gqlmodel.RHVoice { + return gqlmodel.RHVoice{ + Country: e.Country, + Gender: e.Gender, + Lang: e.Lang, + Name: e.Name, + Code: e.Code, + } +} diff --git a/apps/api-gql/internal/delivery/gql/resolvers/overlays-tts.resolver.go b/apps/api-gql/internal/delivery/gql/resolvers/overlays-tts.resolver.go new file mode 100644 index 000000000..85a846a6c --- /dev/null +++ b/apps/api-gql/internal/delivery/gql/resolvers/overlays-tts.resolver.go @@ -0,0 +1,204 @@ +package resolvers + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.45 + +import ( + "bytes" + "context" + "fmt" + "net/url" + + "github.com/goccy/go-json" + "github.com/google/uuid" + req "github.com/imroc/req/v3" + "github.com/samber/lo" + model "github.com/satont/twir/libs/gomodels" + "github.com/satont/twir/libs/types/types/api/modules" + "github.com/twirapp/twir/apps/api-gql/internal/delivery/gql/gqlmodel" + "github.com/twirapp/twir/apps/api-gql/internal/delivery/gql/mappers" + "github.com/twirapp/twir/apps/api-gql/internal/server" +) + +// OverlaysTTSUpdate is the resolver for the overlaysTTSUpdate field. +func (r *mutationResolver) OverlaysTTSUpdate(ctx context.Context, input gqlmodel.OverlaysTTSInput) (*gqlmodel.OverlaysTTSOutput, error) { + dashboardID, err := r.deps.Sessions.GetSelectedDashboard(ctx) + if err != nil { + return nil, err + } + + entity := &model.ChannelModulesSettings{} + if err := r.deps.Gorm. + WithContext(ctx). + Where(`"channelId" = ? AND "type" = ? AND "userId" IS NULL`, dashboardID, "tts"). + Find(entity).Error; err != nil { + return nil, err + } + + settings := &modules.TTSSettings{ + Enabled: &input.Enabled, + Rate: input.Rate, + Volume: input.Volume, + Pitch: input.Pitch, + Voice: input.Voice, + AllowUsersChooseVoiceInMainCommand: input.AllowUsersChooseVoiceInMainCommand, + MaxSymbols: input.MaxSymbols, + DisallowedVoices: input.DisallowedVoices, + DoNotReadEmoji: input.DoNotReadEmoji, + DoNotReadTwitchEmotes: input.DoNotReadTwitchEmotes, + DoNotReadLinks: input.DoNotReadLinks, + ReadChatMessages: input.ReadChatMessages, + ReadChatMessagesNicknames: input.ReadChatMessagesNicknames, + } + bytes, err := json.Marshal(settings) + if err != nil { + return nil, err + } + + if entity.ID == "" { + entity.ID = uuid.New().String() + entity.ChannelId = dashboardID + entity.Type = "tts" + } + + entity.Settings = bytes + if err := r.deps.Gorm.WithContext(ctx).Save(entity).Error; err != nil { + return nil, err + } + + return &gqlmodel.OverlaysTTSOutput{ + Enabled: lo.FromPtr(settings.Enabled), + Rate: settings.Rate, + Volume: settings.Volume, + Pitch: settings.Pitch, + Voice: settings.Voice, + AllowUsersChooseVoiceInMainCommand: settings.AllowUsersChooseVoiceInMainCommand, + MaxSymbols: settings.MaxSymbols, + DisallowedVoices: settings.DisallowedVoices, + DoNotReadEmoji: settings.DoNotReadEmoji, + DoNotReadTwitchEmotes: settings.DoNotReadTwitchEmotes, + DoNotReadLinks: settings.DoNotReadLinks, + ReadChatMessages: settings.ReadChatMessages, + ReadChatMessagesNicknames: settings.ReadChatMessagesNicknames, + }, nil +} + +// OverlaysTts is the resolver for the overlaysTTS field. +func (r *queryResolver) OverlaysTts(ctx context.Context) (*gqlmodel.OverlaysTTSOutput, error) { + dashboardID, err := r.deps.Sessions.GetSelectedDashboard(ctx) + if err != nil { + return nil, err + } + + entity := &model.ChannelModulesSettings{} + if err := r.deps.Gorm. + WithContext(ctx). + Where(`"channelId" = ? AND "type" = ? AND "userId" IS null`, dashboardID, "tts"). + First(entity).Error; err != nil { + return nil, fmt.Errorf("settings not found: %w", err) + } + + settings := &modules.TTSSettings{} + if err := json.Unmarshal(entity.Settings, settings); err != nil { + return nil, fmt.Errorf("cannot parse tts settings: %w", err) + } + + return &gqlmodel.OverlaysTTSOutput{ + Enabled: lo.FromPtr(settings.Enabled), + Rate: settings.Rate, + Volume: settings.Volume, + Pitch: settings.Pitch, + Voice: settings.Voice, + AllowUsersChooseVoiceInMainCommand: settings.AllowUsersChooseVoiceInMainCommand, + MaxSymbols: settings.MaxSymbols, + DisallowedVoices: settings.DisallowedVoices, + DoNotReadEmoji: settings.DoNotReadEmoji, + DoNotReadTwitchEmotes: settings.DoNotReadTwitchEmotes, + DoNotReadLinks: settings.DoNotReadLinks, + ReadChatMessages: settings.ReadChatMessages, + ReadChatMessagesNicknames: settings.ReadChatMessagesNicknames, + }, nil +} + +// OverlaysTTSVoices is the resolver for the overlaysTTSVoices field. +func (r *queryResolver) OverlaysTTSVoices(ctx context.Context) (*gqlmodel.OverlaysTTSVoices, error) { + rhVoices := r.deps.TTSVoicesService.GetRHVoices() + mappedRhVoices := make([]gqlmodel.RHVoice, 0, len(rhVoices)) + for _, v := range rhVoices { + mappedRhVoices = append(mappedRhVoices, mappers.RHVoiceTo(v)) + } + + return &gqlmodel.OverlaysTTSVoices{ + Rhvoices: mappedRhVoices, + }, nil +} + +// OverlaysTTSSay is the resolver for the overlaysTTSSay field. +func (r *queryResolver) OverlaysTTSSay(ctx context.Context) ([]byte, error) { + reqUrl, err := url.Parse(fmt.Sprintf("http://%s/say", r.deps.Config.TTSServiceUrl)) + if err != nil { + return nil, err + } + + query := reqUrl.Query() + + query.Set("voice", "anna") + query.Set("pitch", "50") + query.Set("volume", "30") + query.Set("rate", "50") + query.Set("text", "привет мир") + + reqUrl.RawQuery = query.Encode() + + var b bytes.Buffer + resp, err := req.SetContext(ctx).SetOutput(&b).Get(reqUrl.String()) + if err != nil { + return nil, fmt.Errorf("cannot use say %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("cannot use say %s", resp.String()) + } + if err := server.SetHeader(ctx, "Content-Type", "audio/mp3"); err != nil { + return nil, fmt.Errorf("cannot set header: %w", err) + } + + return b.Bytes(), nil +} + +// !!! WARNING !!! +// The code below was going to be deleted when updating resolvers. It has been copied here so you have +// one last chance to move it out of harms way if you want. There are two reasons this happens: +// - When renaming or deleting a resolver the old code will be put in here. You can safely delete +// it when you're done. +// - You have helper methods in this file. Move them out to keep these resolver files clean. +func (r *mutationResolver) OverlaysTTSSay(ctx context.Context) ([]byte, error) { + reqUrl, err := url.Parse(fmt.Sprintf("http://%s/say", r.deps.Config.TTSServiceUrl)) + if err != nil { + return nil, err + } + + query := reqUrl.Query() + + query.Set("voice", "anna") + query.Set("pitch", "50") + query.Set("volume", "30") + query.Set("rate", "50") + query.Set("text", "привет мир") + + reqUrl.RawQuery = query.Encode() + + var b bytes.Buffer + resp, err := req.SetContext(ctx).SetOutput(&b).Get(reqUrl.String()) + if err != nil { + return nil, fmt.Errorf("cannot use say %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("cannot use say %s", resp.String()) + } + if err := server.SetHeader(ctx, "Content-Type", "audio/mp3"); err != nil { + return nil, fmt.Errorf("cannot set header: %w", err) + } + + return b.Bytes(), nil +} diff --git a/apps/api-gql/internal/delivery/gql/resolvers/resolver.go b/apps/api-gql/internal/delivery/gql/resolvers/resolver.go index fcb38a169..d3fe7e3ae 100644 --- a/apps/api-gql/internal/delivery/gql/resolvers/resolver.go +++ b/apps/api-gql/internal/delivery/gql/resolvers/resolver.go @@ -26,6 +26,7 @@ import ( "github.com/twirapp/twir/apps/api-gql/internal/services/roles_users" "github.com/twirapp/twir/apps/api-gql/internal/services/roles_with_roles_users" "github.com/twirapp/twir/apps/api-gql/internal/services/timers" + ttsvoices "github.com/twirapp/twir/apps/api-gql/internal/services/tts_voices" twir_users "github.com/twirapp/twir/apps/api-gql/internal/services/twir-users" twitchservice "github.com/twirapp/twir/apps/api-gql/internal/services/twitch" "github.com/twirapp/twir/apps/api-gql/internal/services/users" @@ -78,6 +79,7 @@ type Deps struct { RolesUsersService *roles_users.Service RolesWithUsersService *roles_with_roles_users.Service TwitchService *twitchservice.Service + TTSVoicesService *ttsvoices.Service } type Resolver struct { diff --git a/apps/api-gql/internal/delivery/gql/types/bytes.go b/apps/api-gql/internal/delivery/gql/types/bytes.go new file mode 100644 index 000000000..328b29f94 --- /dev/null +++ b/apps/api-gql/internal/delivery/gql/types/bytes.go @@ -0,0 +1,29 @@ +package types + +import ( + "fmt" + "io" + + "github.com/99designs/gqlgen/graphql" +) + +func MarshalBytes(b []byte) graphql.Marshaler { + return graphql.WriterFunc( + func(w io.Writer) { + _, _ = fmt.Fprintf(w, "%q", string(b)) + }, + ) +} + +func UnmarshalBytes(v interface{}) ([]byte, error) { + switch v := v.(type) { + case string: + return []byte(v), nil + case *string: + return []byte(*v), nil + case []byte: + return v, nil + default: + return nil, fmt.Errorf("%T is not []byte", v) + } +} diff --git a/apps/api-gql/internal/entity/tts_voices.go b/apps/api-gql/internal/entity/tts_voices.go new file mode 100644 index 000000000..fdb880c33 --- /dev/null +++ b/apps/api-gql/internal/entity/tts_voices.go @@ -0,0 +1,10 @@ +package entity + +type TTSRHVoice struct { + Code string + Country string + Gender string + Lang string + Name string + No int +} diff --git a/apps/api-gql/internal/server/headers.go b/apps/api-gql/internal/server/headers.go new file mode 100644 index 000000000..7cf9c5330 --- /dev/null +++ b/apps/api-gql/internal/server/headers.go @@ -0,0 +1,18 @@ +package server + +import ( + "context" + + "github.com/twirapp/twir/apps/api-gql/internal/server/gincontext" +) + +func SetHeader(ctx context.Context, key, value string) error { + gin, err := gincontext.GetGinContext(ctx) + if err != nil { + return err + } + + gin.Header(key, value) + + return nil +} diff --git a/apps/api-gql/internal/services/tts_voices/tts_voices.go b/apps/api-gql/internal/services/tts_voices/tts_voices.go new file mode 100644 index 000000000..11990fd85 --- /dev/null +++ b/apps/api-gql/internal/services/tts_voices/tts_voices.go @@ -0,0 +1,100 @@ +package ttsvoices + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/imroc/req/v3" + config "github.com/satont/twir/libs/config" + "github.com/twirapp/twir/apps/api-gql/internal/entity" + "go.uber.org/fx" +) + +type Opts struct { + fx.In + LC fx.Lifecycle + + Config config.Config +} + +func New(opts Opts) *Service { + s := &Service{ + config: opts.Config, + } + + opts.LC.Append( + fx.Hook{ + OnStart: func(ctx context.Context) error { + if err := s.fetchRHVoices(ctx); err != nil { + return err + } + + return nil + }, + }, + ) + + return s +} + +type Service struct { + config config.Config + + rhVoices []entity.TTSRHVoice +} + +type rhvoicesResponse struct { + Voices map[string]rhvoicesResponseVoice `json:"rhvoice_wrapper_voices_info"` +} + +type rhvoicesResponseVoice struct { + Country string `json:"country"` + Gender string `json:"gender"` + Lang string `json:"lang"` + Name string `json:"name"` + No int `json:"no"` +} + +func (c *Service) fetchRHVoices(ctx context.Context) error { + var result rhvoicesResponse + resp, err := req. + R(). + SetContext(ctx). + SetSuccessResult(&result). + Get(fmt.Sprintf("http://%s/info", c.config.TTSServiceUrl)) + if err != nil { + return err + } + if !resp.IsSuccessState() { + return fmt.Errorf("cannot get rh voices: %s", resp.String()) + } + + for key, value := range result.Voices { + c.rhVoices = append( + c.rhVoices, + entity.TTSRHVoice{ + Code: key, + Country: value.Country, + Gender: value.Gender, + Lang: value.Lang, + Name: value.Name, + No: value.No, + }, + ) + } + + slices.SortFunc( + c.rhVoices, + func(a, b entity.TTSRHVoice) int { + return strings.Compare(a.Country, b.Country) + }, + ) + + return nil +} + +func (c *Service) GetRHVoices() []entity.TTSRHVoice { + return c.rhVoices +} diff --git a/apps/api-gql/schema/overlays-tts.graphqls b/apps/api-gql/schema/overlays-tts.graphqls new file mode 100644 index 000000000..5e4ae1c3d --- /dev/null +++ b/apps/api-gql/schema/overlays-tts.graphqls @@ -0,0 +1,53 @@ +extend type Query { + overlaysTTS: OverlaysTTSOutput @isAuthenticated @hasAccessToSelectedDashboard @hasChannelRolesDashboardPermission(permission: MANAGE_OVERLAYS) + overlaysTTSVoices: OverlaysTTSVoices! @isAuthenticated + overlaysTTSSay: Bytes +} + +extend type Mutation { + overlaysTTSUpdate(input: OverlaysTTSInput!): OverlaysTTSOutput! @isAuthenticated @hasAccessToSelectedDashboard @hasChannelRolesDashboardPermission(permission: MANAGE_OVERLAYS) +} + +type OverlaysTTSOutput { + enabled: Boolean! + rate: Int! + volume: Int! + pitch: Int! + voice: String! + allowUsersChooseVoiceInMainCommand: Boolean! + maxSymbols: Int! + disallowedVoices: [String!]! + doNotReadEmoji: Boolean! + doNotReadTwitchEmotes: Boolean! + doNotReadLinks: Boolean! + readChatMessages: Boolean! + readChatMessagesNicknames: Boolean! +} + +type OverlaysTTSVoices { + rhvoices: [RHVoice!]! +} + +type RHVoice { + country: String! + gender: String! + lang: String! + name: String! + code: String! +} + +input OverlaysTTSInput { + enabled: Boolean! + rate: Int! + volume: Int! + pitch: Int! + voice: String! + allowUsersChooseVoiceInMainCommand: Boolean! + maxSymbols: Int! + disallowedVoices: [String!]! + doNotReadEmoji: Boolean! + doNotReadTwitchEmotes: Boolean! + doNotReadLinks: Boolean! + readChatMessages: Boolean! + readChatMessagesNicknames: Boolean! +} diff --git a/apps/api-gql/schema/schema.graphqls b/apps/api-gql/schema/schema.graphqls index 4426d4758..3fd68a769 100644 --- a/apps/api-gql/schema/schema.graphqls +++ b/apps/api-gql/schema/schema.graphqls @@ -17,3 +17,4 @@ directive @validate(constraint: String!) on INPUT_FIELD_DEFINITION | ARGUMENT_DE scalar Upload scalar Time scalar UUID +scalar Bytes