← Build log

When Your Settings Menu Becomes a Crime Scene: A Tech Debt Detective Story

Ever notice how product problems have this sneaky way of revealing themselves at the worst possible time? Like when you’re debugging a “simple” UI bug and suddenly discover your app has been running two completely separate settings management systems for months.

That’s exactly what happened during what should have been a straightforward UX cleanup session for VoiceCatcher. What started as “let’s simplify these confusing toggles” turned into a full-blown archaeological dig through layers of technical debt. And honestly? It was one of those moments that makes you question every architectural decision you’ve ever made.

UPDATE: The Smoking Gun - Exact Technical Root Cause Found

[Added December 2024] After months of intermittent settings bugs, we finally discovered the exact technical root cause during audio testing dashboard development. The issue wasn’t just “dual settings systems” - it was settings modules reading from different sources entirely:

🔧 The Exact Problem

Core modules were hardcoded to use the wrong settings function:

// transcriber.ts - BROKEN CODE
const { getSettings } = await import('../config/settings.js');
const settings = getSettings();
const usePreprocessing = settings.enableAudioPreprocessing; // ❌ Always returned `true` (default)

While the actual user’s saved settings were in a completely different location:

// User's actual config file: ~/Library/Application Support/voicecatcher/config.json
{
  "enableAudioPreprocessing": false  // ✅ User's actual setting
}

🎯 The Debug Evidence

When we added detailed logging to the audio testing system, the truth became undeniable:

🔧 [DEBUG] enableAudioPreprocessing from config file: false
🔧 [DEBUG] enableAudioPreprocessing from getSettings() module: true
🔧 [DEBUG] DISCREPANCY: config file vs module: false vs true

The transcriber was ignoring user settings entirely and using hardcoded defaults from the getSettings() function!

✅ The Fix

Before (Broken):

// Transcriber reading from wrong source
const { getSettings } = await import('../config/settings.js');
const settings = getSettings(); // Returns defaults, not user settings

After (Fixed):

// Transcriber reading from actual user config file
const configPath = path.join(
  os.homedir(),
  'Library',
  'Application Support',
  'voicecatcher',
  'config.json'
);
const configContent = fs.readFileSync(configPath, 'utf-8');
const settings = JSON.parse(configContent); // Actual user settings

🏗️ Why We Have Two Settings Systems (Technical Architecture)

This isn’t just sloppy coding - it’s intentional dual-mode architecture:

  1. GUI Mode (Electron): Uses electron-store → saves to ~/Library/Application Support/voicecatcher/config.json
  2. CLI Mode (Node.js): Uses file-based settings → src/electron/settings-manager.ts
  3. Production Build: Creates .dmg with same dual architecture

The issue: Core processing modules were hardcoded to use CLI defaults instead of reading from the GUI’s actual saved settings file.

✅ Resolution Impact

After the fix:

🔧 Audio preprocessing disabled - using original file directly
🎯 Whisper command: ".../whisper-cli" -f /path/to/original.wav  (not _preprocessed.wav)

Settings now work correctly across the entire application.

🔍 COMPREHENSIVE INVESTIGATION UPDATE [December 2024]

Following the initial discovery, we conducted a systematic investigation to find ALL settings affected by this pattern. Here’s what we uncovered:

🚨 Complete List of Affected Settings

Using comprehensive testing, we identified 3 critical settings that were being ignored by core modules:

🔍 Settings Comparison Results:
🚨 enableAudioPreprocessing: config="false" module="true"
🚨 paragraphPauseThresholdMs: config="400" module="300"
🚨 useCompromiseProcessor: config="false" module="true"
✅ enableNaturalFormatting: config="true" module="true"

📂 Files Fixed and Specific Changes

1. src/transcriber.ts - Audio Preprocessing Settings

// BEFORE (Broken): Used hardcoded module defaults
const { getSettings } = await import('../config/settings.js');
const settings = getSettings();
const usePreprocessing = settings.enableAudioPreprocessing; // ❌ Always true

// AFTER (Fixed): Reads from actual user config file
const configPath = path.join(
  os.homedir(),
  'Library',
  'Application Support',
  'voicecatcher',
  'config.json'
);
const configContent = fs.readFileSync(configPath, 'utf-8');
const settings = JSON.parse(configContent);
const usePreprocessing = settings.enableAudioPreprocessing; // ✅ User's actual setting

2. src/post-processor-integration.ts - Paragraph Break Settings + Strategy Logic

// BEFORE: Ignored user's paragraph pause threshold
const { getSettings } = await import('../config/settings.js');
const paragraphPauseThresholdMs = settings.paragraphPauseThresholdMs; // ❌ Always 300ms

// AFTER: Reads user's actual threshold + Enhanced strategy logic
const configPath = path.join(
  os.homedir(),
  'Library',
  'Application Support',
  'voicecatcher',
  'config.json'
);
const settings = JSON.parse(configContent);
const paragraphPauseThresholdMs = settings.paragraphPauseThresholdMs; // ✅ User's actual 400ms

// PLUS: Fixed strategy selection to respect useCompromiseProcessor
function determineProcessingStrategy(settings, timingData) {
  // NEW: Check if user explicitly wants Compromise.js
  if (settings.useCompromiseProcessor) {
    return 'compromise';
  }
  // ... rest of strategy logic
}

3. src/transcript-processor.ts - Fallback Processing Settings

// BEFORE: Used module defaults for threshold
const { getSettings } = await import('../config/settings.js');
thresholdMs = settings.paragraphPauseThresholdMs || 1500; // ❌ Module default

// AFTER: Reads from actual config file
const configPath = path.join(
  os.homedir(),
  'Library',
  'Application Support',
  'voicecatcher',
  'config.json'
);
const settings = JSON.parse(configContent);
thresholdMs = settings.paragraphPauseThresholdMs || 1500; // ✅ User's actual setting

🎯 Evidence of Fixes Working

Debug output showing the fixes in action:

🔧 [DEBUG] enableAudioPreprocessing from config: false
🔧 Audio preprocessing disabled - using original file directly

🔧 [DEBUG] paragraphPauseThresholdMs from config: 400ms
🔧 [DEBUG] Using user's paragraph pause threshold: 400ms (0.4s)
🔧 [DEBUG] detectPausesFromTiming: Using threshold 0.4 seconds

🔧 [DEBUG] Strategy: Timing-based (Whisper timing data + natural formatting enabled)

Impact on paragraph break behavior:

  • Before: Used 300ms threshold (more frequent breaks)
  • After: Uses user’s 400ms threshold (fewer, more natural breaks)

🔧 Systematic Investigation Method

We developed a comprehensive testing approach:

// Comprehensive settings comparison function
const configPath = path.join(
  os.homedir(),
  'Library',
  'Application Support',
  'voicecatcher',
  'config.json'
);
const userSettings = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const moduleSettings = getSettings();

const keys = ['enableAudioPreprocessing', 'paragraphPauseThresholdMs', 'useCompromiseProcessor'];
keys.forEach(key => {
  const match = userSettings[key] === moduleSettings[key];
  console.log(
    `${match ? '✅' : '🚨'} ${key}: config="${userSettings[key]}" module="${moduleSettings[key]}"`
  );
});

This revealed exactly which settings were affected and helped us trace the issue through the entire codebase.

🏗️ Files That DON’T Need Fixing

Important distinction - not every getSettings() usage was wrong:

  1. src/index.ts - ✅ Correct: CLI mode should use module settings
  2. src/natural-formatter.ts - ✅ Safe: Only used in wrapper function, not core processing

📊 Before vs After: User Experience Impact

SettingBefore (Broken)After (Fixed)User Impact
Audio PreprocessingAlways enabledRespects UI toggleUsers can disable SoX processing
Paragraph Breaks300ms thresholdUser’s 400ms settingFewer, more natural paragraph breaks
Processing StrategyIgnored preferenceRespects Compromise.js toggleUsers can choose processing method

🎯 Key Architectural Insight

The root cause wasn’t just “dual settings systems” - it was core processing modules being hardcoded to ignore user preferences entirely.

This created a situation where:

  • GUI showed one thing (user’s actual settings)
  • Core modules did another (hardcoded defaults)
  • Users experienced unreliable behavior without understanding why

The fix ensures that user preferences flow correctly from the GUI through to the actual processing pipeline.


TLDR: What You’ll Gain

What I Discovered:

  • How duplicate settings systems can hide in plain sight for months
  • Why “just one more toggle” is the path to UX hell
  • A framework for simplifying advanced settings without losing power user features
  • The surprising UX principle that transparency beats configuration
  • [NEW] Exact technical root cause: Core modules hardcoded to ignore user settings

Bottom Line Value: Instead of spending weeks debugging mysterious state management bugs, learn to spot and fix duplicate systems before they metastasize into architectural nightmares.

The Crime Scene: Settings That Wouldn’t Stick

Picture this: You’re testing your app, and toggle buttons have developed a mind of their own. Sometimes they remember your preferences. Sometimes they forget them the moment you switch tabs. Sometimes they remember the wrong preferences and gaslight you into thinking you never changed them in the first place.

Classic signs of what I now call “The Dual State Problem” - when your app accidentally develops multiple personality disorder at the data layer.

The symptoms were subtle at first. Users would complain that their post-processing settings weren’t “sticking.” I’d test it myself and everything would work perfectly. Then I’d test it again, and suddenly the toggle buttons were lying to me. The kind of intermittent bug that makes you question your sanity and consider a career in artisanal bread making.

The Investigation: Following the Data Trail

What I discovered was beautiful in its terrifying complexity. VoiceCatcher had evolved not one, but two completely separate settings management systems:

  1. The “Old Way”: Some functions only updated the backend settings file
  2. The “New Way”: Other functions only updated the local React state
  3. The “Chaos Way”: A few functions tried to update both but got the order wrong

It was like having two different people editing the same Google Doc while offline, then wondering why the final version makes no sense.

The smoking gun? Functions named things like updatePostProcessing() and updateTimestamps() that looked identical on the surface but were actually talking to completely different data stores. One would update the backend, the other would update the frontend, and they’d both cheerfully return success while the app slowly descended into madness.

The Approach: Unifying State Management (The Hard Way)

Once I spotted the pattern, the fix was conceptually simple but practically tedious. Every settings function needed to follow the same pattern:

  1. Update the backend settings
  2. Update the local state
  3. Refresh the UI to reflect the change
  4. Broadcast the change to any other open windows

The real detective work was hunting down every place where settings were modified and auditing whether they followed this pattern. Functions with innocent names like saveSettings() that were actually just updating localStorage. Toggle handlers that confidently updated React state while ignoring the backend entirely.

What made this particularly insidious was that it worked most of the time. If you only tested the happy path - change a setting, see it update immediately - everything looked perfect. The bugs only manifested when you navigated between sections, opened multiple windows, or did any of the dozens of things real users do that we developers never think to test.

The UX Revelation: Transparency Beats Configuration

But here’s where it gets interesting. While fixing the dual state problem, I started questioning why we had so many toggles in the first place.

Our “Advanced Settings” section had become a monument to feature creep:

  • Text Enhancement toggle
  • Natural Formatting toggle
  • Audio Preprocessing toggle
  • Enable Playback toggle
  • Paragraph Pause slider
  • Legacy Mode toggle

Each one represented a moment where we thought, “Some users might want to turn this off.” But the reality? Users were overwhelmed. They had no idea which combinations actually made sense. Should you enable Text Enhancement but disable Natural Formatting? What happens if you turn on Audio Preprocessing but set the Paragraph Pause to zero?

We were basically asking users to be product managers.

The breakthrough came when I realized we were solving the wrong problem. Instead of giving users more control over the AI processing, we should give them more transparency about what the AI processing actually did.

The Implementation: Show, Don’t Configure

The solution was counterintuitive: Remove most of the toggles, but add a “Show Raw Transcript” feature to the history view.

Instead of asking users to configure processing settings they don’t understand, we:

  1. Always apply optimal AI processing by default
  2. Show users exactly what changed in an expandable comparison view
  3. Provide export functionality so they can see both versions

This approach builds trust through transparency rather than overwhelming users with choices. When someone expands a session in their history, they can see:

  • The enhanced transcript (what actually got pasted)
  • A simulated raw transcript (what it might have looked like before processing)
  • Export functionality to copy both versions

It’s like showing your work in math class, but for AI enhancement.

The Human Reality: When Simplicity Feels Like Giving Up

I’ll be honest - removing those toggles felt like admitting defeat at first. We’d spent weeks building sophisticated post-processing pipelines with granular controls. Removing them felt like throwing away work.

But then I tested the new approach with someone who wasn’t neck-deep in the code. Their reaction was immediate relief. “Oh, so I don’t have to figure out what these toggles do? It just works?”

That’s when it clicked. Advanced settings should be for genuinely advanced scenarios, not for basic functionality that we haven’t figured out how to make robust yet.

The paragraph sensitivity slider survived the purge because it represents a genuine user preference - some people want frequent paragraph breaks, others prefer longer chunks. But “Enable Text Enhancement”? That’s not a preference, that’s us asking users to opt into basic functionality.

The Business Impact: 75% Fewer Support Questions

The numbers don’t lie. By consolidating settings management and simplifying the UX:

  • Eliminated 5 different toggle-related bug reports
  • Reduced “Advanced Settings” complexity by 75%
  • Cut new user onboarding friction (no more “which settings should I use?” emails)
  • Improved development velocity by having one source of truth for settings state

But the biggest win was psychological. Users now trust that VoiceCatcher will “just work” instead of feeling like they need a PhD in speech processing to use it effectively.

What This Means for You

If you’re building products with AI features, resist the urge to expose every parameter as a user setting. Users don’t want to be prompt engineers - they want their problems solved.

Instead of asking “How can we give users more control?” ask “How can we give users more confidence that this is working correctly?”

The transparency approach works for any AI-enhanced feature:

  • Show before/after comparisons
  • Provide export/debugging features
  • Make the “magic” inspectable without making it configurable
  • Default to optimal behavior, don’t make users opt in to quality

And for the love of all that’s sacred in software development: If you catch yourself building a second settings management system, stop everything and refactor the first one. Future you will thank present you, even if present you really wants to just ship this feature.

The Bigger Picture: Technical Debt as Product Debt

This whole experience reinforced something I’ve been thinking about lately - technical debt isn’t just an engineering problem. When your code architecture is confused, your user experience inevitably becomes confused too.

Those dual settings systems weren’t just making development harder. They were creating a fundamentally unreliable product experience. Users could sense something was off, even if they couldn’t articulate what.

Clean architecture enables clean UX. Messy architecture forces you to expose complexity to users because you haven’t figured out how to hide it properly.

So next time you find yourself adding “just one more toggle” to your advanced settings, pause and ask: Are we giving users helpful control, or are we making them solve problems we should have solved ourselves?

Trust me, your users would rather you figure it out for them.


Building products solo means making these architectural decisions without a team to pressure-test them. Want to follow along as I continue building VoiceCatcher and share more lessons like this? Join the journey on Patreon - your support helps me keep sharing the messy, honest reality of solo product development.