Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions ai-service/modal_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,35 @@ def process_transcript(self, event: AIEvent) -> Dict[str, Any]:
if not full_text and event.segments:
full_text = " ".join([seg.text for seg in event.segments])

if len(full_text.split()) > 40:
chunk = " ".join(full_text.split()[:500])
exec_summary = self.summarizer(chunk, max_length=100, min_length=30, do_sample=False)[0]['summary_text']
# Improved chunking for long meetings
words = full_text.split()
if len(words) > 40:
# Chunking strategy for large contexts
max_chunk_words = 400
chunks = [" ".join(words[i:i + max_chunk_words]) for i in range(0, len(words), max_chunk_words)]

chunk_summaries = []
for chunk in chunks:
if len(chunk.split()) > 30:
try:
# Summarize each chunk
res = self.summarizer(chunk, max_length=120, min_length=30, do_sample=False)
chunk_summaries.append(res[0]['summary_text'])
except Exception as e:
print(f"Failed to summarize chunk: {e}")
chunk_summaries.append(chunk[:150] + "...")

# If we have multiple chunk summaries, summarize the summaries (map-reduce)
if len(chunk_summaries) > 1:
combined_summaries = " ".join(chunk_summaries)
if len(combined_summaries.split()) > 40:
exec_summary = self.summarizer(combined_summaries, max_length=150, min_length=50, do_sample=False)[0]['summary_text']
else:
exec_summary = combined_summaries
elif len(chunk_summaries) == 1:
exec_summary = chunk_summaries[0]
else:
exec_summary = "Unable to summarize meeting."
else:
exec_summary = full_text

Expand Down
90 changes: 45 additions & 45 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,53 +486,53 @@
// autoCaptionOnRecord: false,

// Transcription options.
// transcription: {
// // Whether the feature should be enabled or not.
// enabled: false,

// // Translation languages.
// // Available languages can be found in
// // ./lang/translation-languages.json.
// translationLanguages: ['en', 'es', 'fr', 'ro'],

// // Important languages to show on the top of the language list.
// translationLanguagesHead: ['en'],

// // If true transcriber will use the application language.
// // The application language is either explicitly set by participants in their settings or automatically
// // detected based on the environment, e.g. if the app is opened in a chrome instance which
// // is using french as its default language then transcriptions for that participant will be in french.
// // Defaults to true.
// useAppLanguage: true,

// // Transcriber language. This settings will only work if "useAppLanguage"
// // is explicitly set to false.
// // Available languages can be found in
// // ./src/react/features/transcribing/transcriber-langs.json.
// preferredLanguage: 'en-US',

// Allows extending the list of supported transcription languages.
// Useful for custom transcription backends (e.g. Vosk).
//
// Example:
// customLanguages: {
// 'hsb-DE': 'Upper Sorbian (Germany)',
// 'dsb-DE': 'Lower Sorbian (Germany)'
// },

// // Enables automatic turning on transcribing when recording is started
// autoTranscribeOnRecord: false,
transcription: {
// Whether the feature should be enabled or not.
enabled: true,

// // Enables automatic request of subtitles when transcriber is present in the meeting, uses the default
// // language that is set
// autoCaptionOnTranscribe: false,
//
// // Disables everything related to closed captions - the tab in the chat area, the button in the menu,
// // subtitles on stage and the "Show subtitles on stage" checkbox in the settings.
// // Note: Starting transcriptions from the recording dialog will still work.
// disableClosedCaptions: false,
// Translation languages.
// Available languages can be found in
// ./lang/translation-languages.json.
translationLanguages: ['en', 'es', 'fr', 'ro'],

Check failure on line 496 in config.js

View workflow job for this annotation

GitHub Actions / Lint

A space is required before ']'

Check failure on line 496 in config.js

View workflow job for this annotation

GitHub Actions / Lint

A space is required after '['

// Important languages to show on the top of the language list.
translationLanguagesHead: ['en'],

Check failure on line 499 in config.js

View workflow job for this annotation

GitHub Actions / Lint

A space is required before ']'

Check failure on line 499 in config.js

View workflow job for this annotation

GitHub Actions / Lint

A space is required after '['

// If true transcriber will use the application language.
// The application language is either explicitly set by participants in their settings or automatically
// detected based on the environment, e.g. if the app is opened in a chrome instance which
// is using french as its default language then transcriptions for that participant will be in french.
// Defaults to true.
useAppLanguage: true,

// Transcriber language. This settings will only work if "useAppLanguage"
// is explicitly set to false.
// Available languages can be found in
// ./src/react/features/transcribing/transcriber-langs.json.
preferredLanguage: 'en-US',

// Allows extending the list of supported transcription languages.
// Useful for custom transcription backends (e.g. Vosk).
//
// Example:
// customLanguages: {
// 'hsb-DE': 'Upper Sorbian (Germany)',
// 'dsb-DE': 'Lower Sorbian (Germany)'
// },

// Enables automatic turning on transcribing when recording is started
autoTranscribeOnRecord: false,

// Enables automatic request of subtitles when transcriber is present in the meeting, uses the default
// language that is set
autoCaptionOnTranscribe: true,

Check failure on line 529 in config.js

View workflow job for this annotation

GitHub Actions / Lint

Trailing spaces not allowed
// Disables everything related to closed captions - the tab in the chat area, the button in the menu,
// subtitles on stage and the "Show subtitles on stage" checkbox in the settings.
// Note: Starting transcriptions from the recording dialog will still work.
disableClosedCaptions: false,

// },
},

// Misc

Expand Down
15 changes: 15 additions & 0 deletions custom.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,18 @@ declare module '*.svg?raw' {
const content: string;
export default content;
}

declare module "*.png" {
const value: any;
export default value;
}

declare module "*.jpg" {
const value: any;
export default value;
}

declare module "*.gif" {
const value: any;
export default value;
}
Binary file modified minutely-api/api.exe
Binary file not shown.
19 changes: 18 additions & 1 deletion minutely-api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,15 @@ func main() {
}
dgClient := deepgram.NewDeepgramClient(deepgramKey)

// --- Action Item Repo ---
actionItemRepo := postgres.NewSupabaseActionItemRepo(client)

// --- AI Processor (Hits Modal serverless endpoint) ---
aiEndpoint := os.Getenv("MODAL_AI_ENDPOINT")
if aiEndpoint == "" {
aiEndpoint = "http://127.0.0.1:8000"
}
aiProcessor := pythonai.NewPythonAIProcessor(aiEndpoint, aiOutputRepo)
aiProcessor := pythonai.NewPythonAIProcessor(aiEndpoint, aiOutputRepo, actionItemRepo)

// --- Services ---
profileService := services.NewProfileService(profileRepo)
Expand Down Expand Up @@ -117,6 +120,8 @@ func main() {
liveHandler := apihttp.NewLiveTranscriptionHandler(transcriptionService, meetingService, wsHub)
aiHandler := apihttp.NewAIHandler(aiOutputRepo)

actionItemHandler := apihttp.NewActionItemHandler(actionItemRepo)

// --- Router ---
r := chi.NewRouter()
r.Use(chimiddleware.Logger)
Expand Down Expand Up @@ -144,10 +149,22 @@ func main() {
r.Use(authMiddleware.Handle)
r.Post("/", handler.CreateMeeting)
r.Get("/", handler.ListMeetings)
r.Get("/summaries", handler.ListMeetingSummaries)
r.Get("/{id}", handler.GetMeeting)
})
})

r.Route("/stats", func(r chi.Router) {
r.Use(authMiddleware.Handle)
r.Get("/", handler.GetDashboardStats)
})

r.Route("/action-items", func(r chi.Router) {
r.Use(authMiddleware.Handle)
r.Get("/", actionItemHandler.ListOpenForUser)
r.Patch("/{id}/status", actionItemHandler.UpdateStatus)
})

r.Route("/jobs", func(r chi.Router) {
r.Get("/{jobId}", transcriptionHandler.GetJobStatus)
r.Get("/{jobId}/progress", transcriptionHandler.GetJobProgress)
Expand Down
25 changes: 21 additions & 4 deletions minutely-api/internal/adapters/audio/ffmpeg.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import (
"os"
"os/exec"
"path/filepath"

"github.com/google/uuid"
)

// AudioExtractor defines an interface for processing audio from video/audio files
type AudioExtractor interface {
// ExtractWav16kHz converts an input file path to a 16kHz mono WAV file path suitable for Whisper
// ExtractWav16kHz converts any input file to a preprocessed 16kHz mono WAV
// suitable for Whisper. Applies audio preprocessing for better transcription accuracy.
ExtractWav16kHz(ctx context.Context, inputFilePath string) (string, error)
// Cleanup removes the temporary files
Cleanup(filePaths ...string)
Expand All @@ -25,6 +27,14 @@ func NewFFmpegExtractor(tempDir string) AudioExtractor {
return &ffmpegExtractor{tempDir: tempDir}
}

// ExtractWav16kHz converts an input media file (audio or video) to a clean
// 16kHz mono WAV with the following preprocessing chain:
//
// 1. highpass=f=80 — removes low-frequency rumble below 80Hz (desk vibrations, mic handling)
// 2. loudnorm — EBU R128 loudness normalization (consistent volume for Whisper)
// I=-16:LRA=11:TP=-1.5 targets broadcast standard loudness
// 3. silenceremove — strips leading/trailing silence longer than 1s at -50dBFS
// reduces unnecessary audio sent to Whisper, lowering cost + latency
func (e *ffmpegExtractor) ExtractWav16kHz(ctx context.Context, inputFilePath string) (string, error) {
if e.tempDir != "" {
if err := os.MkdirAll(e.tempDir, 0755); err != nil {
Expand All @@ -35,19 +45,26 @@ func (e *ffmpegExtractor) ExtractWav16kHz(ctx context.Context, inputFilePath str
outputFileName := fmt.Sprintf("%s.wav", uuid.New().String())
outputFilePath := filepath.Join(e.tempDir, outputFileName)

// Command: ffmpeg -i input.mp4 -ar 16000 -ac 1 -c:a pcm_s16le output.wav
// Audio filter chain for improved transcription accuracy:
// highpass → loudnorm → silenceremove
audioFilter := "highpass=f=80," +
"loudnorm=I=-16:LRA=11:TP=-1.5," +
"silenceremove=start_periods=1:start_silence=1:start_threshold=-50dB" +
":stop_periods=-1:stop_silence=1:stop_threshold=-50dB"

cmd := exec.CommandContext(ctx, "ffmpeg",
"-i", inputFilePath,
"-af", audioFilter,
"-ar", "16000",
"-ac", "1",
"-c:a", "pcm_s16le",
"-y", // overwrite
"-y", // overwrite if exists
outputFilePath,
)

output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("ffmpeg failed: %w, output: %s", err, string(output))
return "", fmt.Errorf("ffmpeg preprocessing failed: %w\nffmpeg output: %s", err, string(output))
}

return outputFilePath, nil
Expand Down
90 changes: 90 additions & 0 deletions minutely-api/internal/adapters/postgres/action_item_repo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package postgres

import (
"context"
"encoding/json"
"errors"

"github.com/google/uuid"
"github.com/supabase-community/supabase-go"

"github.com/MinutelyAI/minutely-api/internal/core/domain"
)

type supabaseActionItemRepo struct {
client *supabase.Client
}

func NewSupabaseActionItemRepo(client *supabase.Client) domain.ActionItemRepository {
return &supabaseActionItemRepo{client: client}
}

func (r *supabaseActionItemRepo) Create(ctx context.Context, item *domain.ActionItem) error {
if item.ID == uuid.Nil {
item.ID = uuid.New()
}

data, _, err := r.client.From("action_items").Insert(item, false, "exact", "representation", "id").Execute()
if err != nil {
return err
}

if len(data) > 0 {
var created []domain.ActionItem
if err := json.Unmarshal(data, &created); err == nil && len(created) > 0 {
*item = created[0]
}
}
return nil
}

func (r *supabaseActionItemRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.ActionItem, error) {
data, _, err := r.client.From("action_items").Select("*", "exact", false).Eq("id", id.String()).Single().Execute()
if err != nil {
return nil, err
}

if len(data) == 0 {
return nil, errors.New("action item not found")
}

var item domain.ActionItem
if err := json.Unmarshal(data, &item); err != nil {
return nil, err
}
return &item, nil
}

func (r *supabaseActionItemRepo) ListByMeetingID(ctx context.Context, meetingID uuid.UUID) ([]*domain.ActionItem, error) {
data, _, err := r.client.From("action_items").Select("*", "exact", false).Eq("meeting_id", meetingID.String()).Order("created_at", nil).Execute()
if err != nil {
return nil, err
}

var items []*domain.ActionItem
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}
return items, nil
}

func (r *supabaseActionItemRepo) ListOpenForUser(ctx context.Context, userID uuid.UUID) ([]*domain.ActionItem, error) {
data, _, err := r.client.From("user_action_items_view").Select("*", "exact", false).
Eq("meeting_owner_id", userID.String()).
Eq("status", string(domain.ActionItemStatusOpen)).
Order("due_date", nil).Execute()
if err != nil {
return nil, err
}

var items []*domain.ActionItem
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}
return items, nil
}

func (r *supabaseActionItemRepo) Update(ctx context.Context, item *domain.ActionItem) error {
_, _, err := r.client.From("action_items").Update(item, "exact", "id").Eq("id", item.ID.String()).Execute()
return err
}
19 changes: 18 additions & 1 deletion minutely-api/internal/adapters/postgres/dummy_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package postgres

import (
"context"
"sync"

"github.com/google/uuid"
"github.com/MinutelyAI/minutely-api/internal/core/domain"
Expand All @@ -22,7 +23,10 @@ func (r *dummyProfileRepo) Update(ctx context.Context, profile *domain.Profile)
return nil
}

type dummyMeetingRepo struct{}
type dummyMeetingRepo struct {
mu sync.Mutex
meetings []*domain.Meeting
}

func NewDummyMeetingRepo() domain.MeetingRepository {
return &dummyMeetingRepo{}
Expand All @@ -44,3 +48,16 @@ func (r *dummyMeetingRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]
func (r *dummyMeetingRepo) Update(ctx context.Context, meeting *domain.Meeting) error {
return nil
}

func (r *dummyMeetingRepo) GetDashboardStats(ctx context.Context, userID uuid.UUID) (*domain.DashboardStats, error) {
return &domain.DashboardStats{
TotalMeetings: 0,
HoursTranscribed: 0,
OpenActionItems: 0,
PeopleMet: 0,
}, nil
}

func (r *dummyMeetingRepo) ListSummaries(ctx context.Context, userID uuid.UUID) ([]*domain.MeetingSummary, error) {
return []*domain.MeetingSummary{}, nil
}
Loading
Loading