Skip to content

feat: Add signal generator and loopback measurement to capture agent v0.2.0 - #100

Merged
cj-vana merged 3 commits into
betafrom
new-pr
Sep 25, 2025
Merged

cj-vana merged 3 commits into
betafrom
new-pr

Conversation

@cj-vana

@cj-vana cj-vana commented Sep 25, 2025 •

Copy link
Copy Markdown
Collaborator

User description

  • Implement comprehensive signal generation capabilities:
    • Pink noise generator using Voss-McCartney algorithm
    • White noise generator for system calibration
    • Sine wave generator with 20Hz-20kHz frequency range
    • Logarithmic sine sweep for system testing
    • Configurable output routing to any audio channel

    • Add internal loopback measurement mode:
      • Route generated signals directly to reference channel
      • Enable measurement of DSP chains and software signal paths
      • Support for analyzing systems without physical connections

    • Update version numbers:
      • Capture agent: 0.1.14 → 0.2.0 (major feature release)
      • Web app: 1.5.6.6 → 1.5.6.7
      • Update GitHub CI workflow installer versions


PR Type

Enhancement


Description

  • Add comprehensive signal generator with multiple waveforms

  • Implement internal loopback measurement mode

  • Update capture agent to v0.2.0

  • Enhance web UI with signal generator controls


Diagram Walkthrough

flowchart LR
  A["Signal Generator"] --> B["Audio Output"]
  B --> C["Loopback Mode"]
  C --> D["Reference Channel"]
  E["Web UI Controls"] --> A
  F["Protocol Updates"] --> A
Loading

File Walkthrough

Relevant files
Enhancement
7 files
index.ts
Add signal generator protocol definitions                               
+23/-1   
schema.py
Add signal generator and loopback schemas                               
+21/-2   
server.py
Implement signal generation and loopback functionality     
+241/-25
signal_generator.py
Create comprehensive signal generator module                         
+269/-0 
ProSettings.tsx
Add loopback mode and generator integration                           
+33/-9   
SignalGeneratorSettings.tsx
Create signal generator UI component                                         
+373/-0 
AnalyzerProPage.tsx
Integrate signal generator into analyzer page                       
+41/-1   
Formatting
2 files
audio.py
Remove debug print statements                                                       
+1/-1     
dsp.py
Remove debug prints and increase delay limit                         
+4/-4     
Configuration changes
3 files
index.html
Update web app version number                                                       
+1/-1     
build-installers.yml
Update installer version to 0.2.0                                               
+4/-4     
package.json
Bump web app version                                                                         
+1/-1     
Documentation
2 files
CHANGELOG.md
Document v1.5.6.7 release features                                             
+23/-0   
README.md
Add privacy promise statement                                                       
+2/-0     
Dependencies
1 files
pyproject.toml
Update version and add colorednoise dependency                     
+3/-2     

…v0.2.0

  - Implement comprehensive signal generation capabilities:
    • Pink noise generator using Voss-McCartney algorithm
    • White noise generator for system calibration
    • Sine wave generator with 20Hz-20kHz frequency range
    • Logarithmic sine sweep for system testing
    • Click track generator with adjustable BPM
    • Configurable output routing to any audio channel

  - Add internal loopback measurement mode:
    • Route generated signals directly to reference channel
    • Enable measurement of DSP chains and software signal paths
    • Support for analyzing systems without physical connections

  - Update version numbers:
    • Capture agent: 0.1.14 → 0.2.0 (major feature release)
    • Web app: 1.5.6.6 → 1.5.6.7
    • Update GitHub CI workflow installer versions
@netlify

netlify Bot commented Sep 25, 2025 •

Copy link
Copy Markdown

✅ Deploy Preview for sounddocsbeta ready!

Name Link
🔨 Latest commit 6f0e9bf
🔍 Latest deploy log https://app.netlify.com/projects/sounddocsbeta/deploys/68d55de9ac344e0008d01664
😎 Deploy Preview https://deploy-preview-100--sounddocsbeta.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Concurrency/Globals

Global signal_generator and generator_config are mutated from websocket handlers while capture callbacks may read them, without synchronization. This could cause race conditions or None access during duplex callback; consider encapsulating per-capture state or guarding updates.

    elif message.type == "update_generator":
        global signal_generator, generator_config
        generator_config = message.config
        if generator_config.enabled:
            # Create or update signal generator
            gen_config = GenConfig(
                signal_type=SignalType(generator_config.signalType),
                sample_rate=48000,  # Will be updated when capture starts
                output_channels=generator_config.outputChannels,
                frequency=generator_config.frequency,
                start_freq=generator_config.startFreq,
                end_freq=generator_config.endFreq,
                sweep_duration=generator_config.sweepDuration,
                amplitude=generator_config.amplitude
            )
            signal_generator = SignalGenerator(gen_config)
        else:
            signal_generator = None
        await ws.send(json.dumps({"type": "generator_updated", "enabled": generator_config.enabled if generator_config else False}))

async def run_capture(ws, config: CaptureConfig):
    global signal_generator
    loop = asyncio.get_running_loop()
    aq: asyncio.Queue[np.ndarray] = asyncio.Queue(maxsize=128)  # Increased from 32 to prevent frame drops
    num_channels = max(config.refChan, config.measChan)

    # Initialize signal generator if configured
    use_generator = False
    if config.generator and config.generator.enabled:
        # Initialize signal generator with config
        gen_config = GenConfig(
            signal_type=SignalType(config.generator.signalType),
            sample_rate=config.sampleRate,
            output_channels=config.generator.outputChannels,
            frequency=config.generator.frequency,
            start_freq=config.generator.startFreq,
            end_freq=config.generator.endFreq,
            sweep_duration=config.generator.sweepDuration,
            amplitude=config.generator.amplitude
        )
        signal_generator = SignalGenerator(gen_config)
        use_generator = True
Channel Indexing

When applying loopback, code assumes config.refChan - 1 exists and generated_signal_buffer length equals frames. Edge cases (mono devices, misconfigured channels, outputChannels empty) may break silently due to pass; add bounds checks and fallback behavior.

    # If using loopback, inject generated signal to reference channel
    if config.useLoopback and generated_signal_buffer is not None and len(generated_signal_buffer) == frames:
        # Replace reference channel with generated signal
        ref_idx = config.refChan - 1  # Convert to 0-indexed
        if ref_idx < buf.shape[1]:
            buf[:, ref_idx] = generated_signal_buffer

except IndexError:
    # pool exhausted - track misses
    pool_miss_count += 1
    # Create new buffer only if under max size
    if len(pool) < max_pool_size:
        buf = indata.copy() if indata.dtype == np.float32 else indata.astype(np.float32, copy=True)

        # Apply loopback even to newly created buffer
        if config.useLoopback and generated_signal_buffer is not None and len(generated_signal_buffer) == frames:
            ref_idx = config.refChan - 1
            if ref_idx < buf.shape[1]:
                buf[:, ref_idx] = generated_signal_buffer
    else:
Dependency/Behavior Mismatch

Changelog/UI mentions Voss–McCartney pink noise and a click track, but implementation uses colorednoise PSD and no click track. Align docs/UI or add implementations to avoid user confusion.

import numpy as np
try:
    import colorednoise as cn
    COLOREDNOISE_AVAILABLE = True
except ImportError:
    print("WARNING: colorednoise not installed. Colored noise generation will not work.")
    COLOREDNOISE_AVAILABLE = False
from typing import Optional, List
from dataclasses import dataclass
from enum import Enum

class SignalType(Enum):
    SINE = "sine"
    WHITE_NOISE = "white"
    PINK_NOISE = "pink"
    BROWN_NOISE = "brown"
    BLUE_NOISE = "blue"
    VIOLET_NOISE = "violet"
    SINE_SWEEP = "sine_sweep"

@dataclass
class GeneratorConfig:
    signal_type: SignalType
    sample_rate: int = 48000
    output_channels: List[int] = None  # Which channels to output to, None = all
    # Sine specific
    frequency: float = 1000.0  # Hz
    # Sine sweep specific
    start_freq: float = 20.0  # Hz
    end_freq: float = 20000.0  # Hz
    sweep_duration: float = 1.0  # seconds
    # Noise specific
    noise_color: Optional[float] = None  # Beta parameter for colorednoise
    # General
    amplitude: float = 0.5  # 0.0 to 1.0

class SignalGenerator:
    """Generates various test signals for audio measurement."""

    def __init__(self, config: GeneratorConfig):

@qodo-code-review

qodo-code-review Bot commented Sep 25, 2025 •

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to e25ce6b

CategorySuggestion                                                                                                                                    Impact
Incremental [*]
Prevent identical ref/meas with loopback

Prevent the measurement channel (measChan) from being the same as the reference
channel (refChan) when loopback is enabled, as this would result in an invalid
transfer function measurement.

apps/web/src/components/analyzer/ProSettings.tsx [32-131]

 const [refChan, setRefChan] = useState<number | string>(1);
 const [measChan, setMeasChan] = useState<number>(2);
-...
-<select
-  value={useLoopback ? "loopback" : refChan}
-  onChange={(e) => {
-    if (e.target.value === "loopback") {
-      setUseLoopback(true);
-      setRefChan(1); // Keep a default channel for internal use
-    } else {
-      setUseLoopback(false);
-      setRefChan(Number(e.target.value));
+const [useLoopback, setUseLoopback] = useState<boolean>(false);
+
+useEffect(() => {
+  if (useLoopback) {
+    // Force refChan to 1 internally and ensure measChan != 1
+    if (measChan === 1) {
+      const next = selectedDevice ? Math.min(Math.max(2, 1 + 1), selectedDevice.inputs || 2) : 2;
+      setMeasChan(next);
     }
-  }}
-  disabled={isCapturing || !selectedDevice}
-  className="w-full px-3 py-2 bg-gray-600 border border-gray-500 rounded-lg text-white disabled:opacity-50"
->
-  <option value="loopback">Loopback (Signal Generator)</option>
-  {selectedDevice && renderChannelOptions(selectedDevice.inputs)}
-</select>
+  }
+}, [useLoopback, measChan, selectedDevice]);
 
+const handleStart = () => {
+  if (!selectedDeviceId) {
+    alert("Please select an audio device.");
+    return;
+  }
+  if (useLoopback && measChan === 1) {
+    alert("With loopback enabled, choose a measurement channel different from the reference channel (1).");
+    return;
+  }
+  const config: CaptureConfig = {
+    deviceId: selectedDeviceId,
+    sampleRate: 48000,
+    blockSize: 1024,
+    refChan: useLoopback ? 1 : (refChan as number),
+    measChan,
+    nfft,
+    avg: "power",
+    avgCount: 0,
+    window: "hann",
+    lpfMode: "none",
+    lpfFreq: 0,
+    useLoopback,
+    generator: signalGeneratorConfig as any,
+  };
+  onStartCapture(config);
+};
+

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 8

__

Why: This suggestion correctly identifies a valid bug where enabling loopback does not prevent the user from selecting the same channel for both reference and measurement, which would invalidate the analysis.

Medium
Possible issue
Align output channels to routing

When creating a full-duplex sd.Stream, calculate the number of output channels
based on the signal generator's configuration instead of using the device's
maximum to prevent potential stream initialization failures.

agents/capture-agent-py/capture_agent/server.py [369-380]

-blocksize = 4096  # Even larger block size for maximum stability
+# Determine actual output channel count to request
+if use_generator and out_channels > 0:
+    if config.generator and config.generator.outputChannels:
+        max_requested = max(config.generator.outputChannels)
+        out_out_channels = min(max_requested, out_channels)
+    else:
+        out_out_channels = out_channels
+    if out_out_channels <= 0:
+        out_out_channels = 1
+else:
+    out_out_channels = 0
 stream = sd.Stream(
     device=int(config.deviceId),
     samplerate=config.sampleRate,
     blocksize=blocksize,
-    channels=(num_channels, out_channels),  # (input_channels, output_channels)
+    channels=(num_channels, out_out_channels),  # (input, output)
     dtype="float32",
     callback=duplex_callback,
-    latency=("high", "high"),  # Higher latency for both input and output for stability
-    prime_output_buffers_using_stream_callback=True,  # Pre-fill output buffers
-    dither_off=True  # Disable dithering for cleaner signal
+    latency=("high", "high"),
+    prime_output_buffers_using_stream_callback=True,
+    dither_off=True
 )
  • Apply / Chat
Suggestion importance[1-10]: 8

__

Why: This suggestion correctly points out that requesting the maximum number of output channels from sd.Stream can cause errors; instead, it should request only the number of channels needed, improving stability.

Medium
Normalize device info access

To prevent potential runtime errors across different platforms, ensure the
result from sd.query_devices() is a standard dictionary before accessing its
keys.

agents/capture-agent-py/capture_agent/server.py [163-165]

-device_info = sd.query_devices(int(config.deviceId))
-out_channels = device_info.get('max_output_channels', 0)
-in_channels = device_info.get('max_input_channels', 0)
+dev_info_raw = sd.query_devices(int(config.deviceId))
+device_info = dict(dev_info_raw) if not isinstance(dev_info_raw, dict) else dev_info_raw
+out_channels = device_info.get('max_output_channels', 0) or 0
+in_channels = device_info.get('max_input_channels', 0) or 0
  • Apply / Chat
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies a potential cross-platform issue where sd.query_devices() can return non-dict types, making the code more robust by ensuring the result is a standard dictionary.

Low
General
Remove noisy import-time prints

Remove the print statement from the try...except ImportError block to prevent
unnecessary console output when the colorednoise package is not found.

agents/capture-agent-py/capture_agent/signal_generator.py [2-7]

 try:
     import colorednoise as cn
     COLOREDNOISE_AVAILABLE = True
 except ImportError:
-    print("WARNING: colorednoise not installed. Colored noise generation will not work.")
-    COLOREDNOISE_AVAILABLE = False
+    COLOREDNOISE_AVAILABLE = False  # Colored noise unavailable; will fall back silently
  • Apply / Chat
Suggestion importance[1-10]: 4

__

Why: The suggestion correctly advises removing a print statement from module-level code to avoid noisy logs, which is good practice for library and agent code, improving the application's cleanliness.

Low
  • Update

Previous suggestions

Suggestions up to commit a2b5992
CategorySuggestion                                                                                                                                    Impact
High-level
Implement the missing click track generator

The "click track generator" feature, mentioned in the PR description and
changelog, is missing from the implementation. This functionality should be
added to the signal generator module and the web UI.

Examples:

agents/capture-agent-py/capture_agent/signal_generator.py [12-20]
class SignalType(Enum):
    SINE = "sine"
    WHITE_NOISE = "white"
    PINK_NOISE = "pink"
    BROWN_NOISE = "brown"
    BLUE_NOISE = "blue"
    VIOLET_NOISE = "violet"
    SINE_SWEEP = "sine_sweep"
apps/web/src/components/analyzer/SignalGeneratorSettings.tsx [74-82]
  const signalTypes = [
    { value: "sine", label: "Sine", icon: Waves },
    { value: "sine_sweep", label: "Sweep", icon: TrendingUp },
    { value: "white", label: "White", icon: Activity },
    { value: "pink", label: "Pink", icon: Activity },
    { value: "brown", label: "Brown", icon: Activity },
    { value: "blue", label: "Blue", icon: Activity },
    { value: "violet", label: "Violet", icon: Activity },
  ];

Solution Walkthrough:

Before:

# agents/capture-agent-py/capture_agent/schema.py
class SignalGeneratorConfig(BaseModel):
    signalType: Literal["sine", "white", "pink", "brown", "blue", "violet", "sine_sweep"] = "sine"
    # ... no BPM or click-related fields

# agents/capture-agent-py/capture_agent/signal_generator.py
class SignalType(Enum):
    SINE = "sine"
    # ... other signal types
    SINE_SWEEP = "sine_sweep"
    # No CLICK_TRACK type

# apps/web/src/components/analyzer/SignalGeneratorSettings.tsx
const signalTypes = [
  { value: "sine", label: "Sine" },
  { value: "sine_sweep", label: "Sweep" },
  // ... other noise types
  // No "Click" option in the UI
];

After:

# agents/capture-agent-py/capture_agent/schema.py
class SignalGeneratorConfig(BaseModel):
    signalType: Literal["sine", ..., "sine_sweep", "click"] = "sine"
    bpm: float = 120.0 # Add BPM for click track
    # ...

# agents/capture-agent-py/capture_agent/signal_generator.py
class SignalType(Enum):
    # ... other signal types
    SINE_SWEEP = "sine_sweep"
    CLICK_TRACK = "click" # Add click track type

# apps/web/src/components/analyzer/SignalGeneratorSettings.tsx
const signalTypes = [
  // ... other types
  { value: "click", label: "Click", icon: MetronomeIcon }, // Add UI option
];

// Add UI controls for BPM when signalType is "click"
Suggestion importance[1-10]: 9

__

Why: This suggestion is highly relevant and accurate, as it points out a significant feature ("click track generator") that was advertised in the PR description and changelog but is completely missing from the implementation.

High

@qodo-code-review

qodo-code-review Bot commented Sep 25, 2025 •

Copy link
Copy Markdown
Contributor

CI Feedback 🧐

(Feedback updated until commit 6f0e9bf)

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: typescript-checks

Failed stage: ESLint on changed files [❌]

Failure summary:

The action failed because ESLint reported errors in the changed files, and the workflow was
configured with --max-warnings 0 to fail on any lint errors.
Reported ESLint errors:
-
apps/web/src/components/analyzer/ProSettings.tsx
- 13:27 error: Unexpected any. Specify a
different type (@typescript-eslint/no-explicit-any)
- 34:16 error: setNfft is assigned a value but
never used (@typescript-eslint/no-unused-vars)
-
apps/web/src/components/analyzer/SignalGeneratorSettings.tsx
- 2:48 error: ChevronRight is defined
but never used (@typescript-eslint/no-unused-vars)
- apps/web/src/pages/AnalyzerProPage.tsx
-
20:31 error: useMemo is defined but never used (@typescript-eslint/no-unused-vars)
Summary: 4
errors, 2 warnings. The script printed "❌ ESLint found issues in changed files" and exited with code
1.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

171:  + prettier 3.5.3
172:  + typescript 5.9.2
173:  + typescript-eslint 8.39.1
174:  . prepare$ husky
175:  . prepare: Done
176:  Done in 1.8s using pnpm v9.15.9
177:  ##[group]Run # Convert comma-separated list to space-separated for ESLint
178:  �[36;1m# Convert comma-separated list to space-separated for ESLint�[0m
179:  �[36;1mFILES="apps/web/src/components/analyzer/SignalGeneratorSettings.tsx,packages/analyzer-protocol/src/index.ts,apps/web/src/components/analyzer/ProSettings.tsx,apps/web/src/pages/AnalyzerProPage.tsx"�[0m
180:  �[36;1mFILES_ARRAY=$(echo "$FILES" | tr ',' ' ')�[0m
181:  �[36;1m�[0m
182:  �[36;1mecho "Running ESLint on changed files:"�[0m
183:  �[36;1mecho "$FILES_ARRAY" | tr ' ' '\n'�[0m
184:  �[36;1m�[0m
185:  �[36;1m# Run ESLint only on changed files�[0m
186:  �[36;1m# Note: ESLint might need context from imported files, but will only report errors in specified files�[0m
187:  �[36;1mnpx eslint $FILES_ARRAY --max-warnings 0 || EXIT_CODE=$?�[0m
188:  �[36;1m�[0m
189:  �[36;1m# If ESLint found issues, fail the check�[0m
190:  �[36;1mif [ "${EXIT_CODE:-0}" -ne 0 ]; then�[0m
191:  �[36;1m  echo "❌ ESLint found issues in changed files"�[0m
192:  �[36;1m  exit 1�[0m
193:  �[36;1melse�[0m
194:  �[36;1m  echo "✅ ESLint checks passed"�[0m
195:  �[36;1mfi�[0m
196:  shell: /usr/bin/bash -e {0}
197:  env:
198:  PNPM_HOME: /home/runner/setup-pnpm/node_modules/.bin
199:  ##[endgroup]
200:  Running ESLint on changed files:
201:  apps/web/src/components/analyzer/SignalGeneratorSettings.tsx
202:  packages/analyzer-protocol/src/index.ts
203:  apps/web/src/components/analyzer/ProSettings.tsx
204:  apps/web/src/pages/AnalyzerProPage.tsx
205:  /home/runner/work/sounddocs/sounddocs/apps/web/src/components/analyzer/ProSettings.tsx
206:  ##[error]  13:27  error  Unexpected any. Specify a different type      @typescript-eslint/no-explicit-any
207:  ##[error]  34:16  error  'setNfft' is assigned a value but never used  @typescript-eslint/no-unused-vars
208:  /home/runner/work/sounddocs/sounddocs/apps/web/src/components/analyzer/SignalGeneratorSettings.tsx
209:  ##[error]   2:48  error    'ChevronRight' is defined but never used                                                                                                                                                                                                     @typescript-eslint/no-unused-vars
210:  ##[warning]  45:6   warning  React Hook useEffect has a missing dependency: 'onConfigChange'. Either include it or remove the dependency array. If 'onConfigChange' changes too often, find the parent component that defines it and wrap that definition in useCallback  react-hooks/exhaustive-deps
211:  /home/runner/work/sounddocs/sounddocs/apps/web/src/pages/AnalyzerProPage.tsx
212:  ##[error]   20:31  error    'useMemo' is defined but never used                                                                                   @typescript-eslint/no-unused-vars
213:  ##[warning]  168:6   warning  React Hook useEffect has a missing dependency: 'fetchMeasurements'. Either include it or remove the dependency array  react-hooks/exhaustive-deps
214:  ✖ 6 problems (4 errors, 2 warnings)
215:  ❌ ESLint found issues in changed files
216:  ##[error]Process completed with exit code 1.
217:  Post job cleanup.

@cj-vana
cj-vana merged commit fa3c0ff into beta Sep 25, 2025
6 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant