diff --git a/.claude/hooks/capture_session_event.py b/.claude/hooks/capture_session_event.py new file mode 100755 index 0000000..46f7dfe --- /dev/null +++ b/.claude/hooks/capture_session_event.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Hook to capture session start/end events. +Cross-platform support for Windows, macOS, and Linux. +""" +import json +import sys +import os +import subprocess +from datetime import datetime, timezone +from claude_code_capture_utils import get_log_file_path, add_ab_metadata + +def get_git_metadata(repo_dir): + """Get current git commit and branch.""" + try: + # Get current commit hash + commit_result = subprocess.run( + ['git', 'rev-parse', 'HEAD'], + cwd=repo_dir, + capture_output=True, + text=True, + timeout=10 + ) + + # Get current branch + branch_result = subprocess.run( + ['git', 'branch', '--show-current'], + cwd=repo_dir, + capture_output=True, + text=True, + timeout=10 + ) + + git_metadata = { + "base_commit": commit_result.stdout.strip() if commit_result.returncode == 0 else None, + "branch": branch_result.stdout.strip() if branch_result.returncode == 0 else None, + "timestamp": datetime.now(timezone.utc).isoformat() + } + + if git_metadata["base_commit"]: + return git_metadata + else: + return None + + except Exception as e: + print(f"Warning: Could not capture git metadata: {e}", file=sys.stderr) + return None + +def main(): + try: + if len(sys.argv) < 2: + print("Usage: capture_session_event.py [start|end]", file=sys.stderr) + sys.exit(1) + + event_type = sys.argv[1].lower() + if event_type not in ["start", "end"]: + print("Event type must be 'start' or 'end'", file=sys.stderr) + sys.exit(1) + + input_data = json.load(sys.stdin) + + session_id = input_data.get("session_id", "unknown") + transcript_path = input_data.get("transcript_path", "") + cwd = input_data.get("cwd", "") + + if event_type == "start": + # Session start: capture git metadata + git_metadata = get_git_metadata(cwd) + + log_entry = { + "type": "session_start", + "timestamp": datetime.now(timezone.utc).isoformat(), + "session_id": session_id, + "transcript_path": transcript_path, + "cwd": cwd, + "git_metadata": git_metadata + } + + log_entry = add_ab_metadata(log_entry, cwd) + + if git_metadata: + print(f"[OK] Captured git metadata: {git_metadata['base_commit'][:8]} on {git_metadata['branch']}") + + # Write session_start event + log_file = get_log_file_path(session_id, cwd) + with open(log_file, "a", encoding="utf-8") as f: + f.write(json.dumps(log_entry) + "\n") + + elif event_type == "end": + # Session end: log the event + log_entry = { + "type": "session_end", + "timestamp": datetime.now(timezone.utc).isoformat(), + "session_id": session_id, + "transcript_path": transcript_path, + "cwd": cwd, + "reason": input_data.get("reason", "") + } + + log_entry = add_ab_metadata(log_entry, cwd) + + # Write session_end event + log_file = get_log_file_path(session_id, cwd) + with open(log_file, "a", encoding="utf-8") as f: + f.write(json.dumps(log_entry) + "\n") + + except Exception as e: + print(f"[ERROR] Session {event_type}: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/claude_code_capture_utils.py b/.claude/hooks/claude_code_capture_utils.py new file mode 100755 index 0000000..fe2d754 --- /dev/null +++ b/.claude/hooks/claude_code_capture_utils.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Utility functions for A/B testing hooks. +Cross-platform support for Windows, macOS, and Linux. +""" +import json +import os +from pathlib import Path + +def detect_model_lane(cwd): + """Detect if we're in model_a or model_b directory.""" + path_parts = Path(cwd).parts + if 'model_a' in path_parts: + return 'model_a' + elif 'model_b' in path_parts: + return 'model_b' + return None + +def get_experiment_root(cwd): + """Get the experiment root directory (parent of model_a/model_b).""" + current_path = Path(cwd) + + # Check if we're inside a model_a or model_b directory + # Look for the parent that contains both model_a and model_b + for parent in [current_path] + list(current_path.parents): + if (parent / 'model_a').exists() and (parent / 'model_b').exists(): + return str(parent) + # Also check one level up (in case we're inside the cloned repo) + parent_up = parent.parent + if (parent_up / 'model_a').exists() and (parent_up / 'model_b').exists(): + return str(parent_up) + return None + +def read_manifest(experiment_root): + """Read the manifest.json file to get task_id and model assignments.""" + try: + manifest_path = os.path.join(experiment_root, 'manifest.json') + if os.path.exists(manifest_path): + with open(manifest_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + pass + return {} + +def get_ab_metadata(cwd): + """Get A/B testing metadata (task_id, model_lane, model_name) from current directory.""" + model_lane = detect_model_lane(cwd) + experiment_root = get_experiment_root(cwd) + + if not model_lane or not experiment_root: + return {} + + manifest = read_manifest(experiment_root) + + metadata = { + "task_id": manifest.get("task_id"), + "model_lane": model_lane, + "experiment_root": experiment_root + } + + # Get model name from assignments + assignments = manifest.get("assignments", {}) + if model_lane in assignments: + metadata["model_name"] = assignments[model_lane] + + return metadata + +def get_log_file_path(session_id, cwd): + """Get the correct log file path for A/B testing (routes to model-specific directory).""" + model_lane = detect_model_lane(cwd) + experiment_root = get_experiment_root(cwd) + + if model_lane and experiment_root: + # Route to model-specific logs directory + logs_dir = os.path.join(experiment_root, "logs", model_lane) + os.makedirs(logs_dir, exist_ok=True) + return os.path.join(logs_dir, f"session_{session_id}.jsonl") + else: + # Fallback to current behavior + project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()) + logs_dir = os.path.join(project_dir, "logs") + os.makedirs(logs_dir, exist_ok=True) + return os.path.join(logs_dir, f"session_{session_id}.jsonl") + +def add_ab_metadata(event, cwd): + """Add A/B testing metadata to an event.""" + ab_metadata = get_ab_metadata(cwd) + if ab_metadata: + for key, value in ab_metadata.items(): + if value is not None: + event[key] = value + return event + diff --git a/.claude/hooks/process_transcript.py b/.claude/hooks/process_transcript.py new file mode 100755 index 0000000..955560d --- /dev/null +++ b/.claude/hooks/process_transcript.py @@ -0,0 +1,720 @@ +#!/usr/bin/env python3 +""" +Process raw transcript to extract all messages and generate summaries. +Handles both incremental (Stop event) and final (SessionEnd) processing. +Cross-platform support for Windows, macOS, and Linux. +""" +import json +import sys +import os +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from collections import defaultdict +from claude_code_capture_utils import get_log_file_path, add_ab_metadata, detect_model_lane, get_experiment_root + +def read_and_process_raw_transcript(transcript_path): + """ + Read raw transcript and extract all unique messages. + Returns deduplicated messages with last occurrence (final state). + Also extracts thinking blocks as separate entries. + """ + if not os.path.exists(transcript_path): + return [] + + # Track assistant messages by message.id (they have IDs, can have duplicates) + assistant_messages = {} + # Track thinking blocks separately (won't be counted in token usage) + thinking_blocks = {} + # Track user messages by uuid (they don't have message.id, use uuid) + user_messages = {} + + try: + with open(transcript_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + + try: + event = json.loads(line) + event_type = event.get('type') + message = event.get('message', {}) + + # Process assistant messages (have message.id) + if event_type == 'assistant': + msg_id = message.get('id') + if msg_id: + # Check for thinking blocks in content + content = message.get('content', []) + if isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get('type') == 'thinking': + # Extract thinking block as separate entry + thinking_entry = { + 'type': 'assistant_thinking', + 'timestamp': event.get('timestamp'), + 'message_id': msg_id, + 'thinking_content': item.get('thinking', ''), + 'session_id': event.get('sessionId'), + 'cwd': event.get('cwd') + } + # Use message_id as key (one thinking block per message) + thinking_blocks[msg_id] = thinking_entry + + # Store/overwrite with last occurrence (streaming) + assistant_msg = { + 'type': event_type, + 'timestamp': event.get('timestamp'), + 'message': message, + 'session_id': event.get('sessionId'), + 'cwd': event.get('cwd') + } + + # Preserve stop_reason from message if present + if message.get('stop_reason'): + assistant_msg['stop_reason'] = message['stop_reason'] + + assistant_messages[msg_id] = assistant_msg + + # Process user messages (use uuid as key, no message.id) + elif event_type == 'user': + uuid = event.get('uuid') + if uuid: + user_msg = { + 'type': event_type, + 'timestamp': event.get('timestamp'), + 'message': message, + 'session_id': event.get('sessionId'), + 'cwd': event.get('cwd'), + 'uuid': uuid + } + + # Preserve thinking metadata if present + if 'thinkingMetadata' in event: + user_msg['thinkingMetadata'] = event['thinkingMetadata'] + + # Preserve isMeta flag if present + if event.get('isMeta'): + user_msg['isMeta'] = event['isMeta'] + + user_messages[uuid] = user_msg + + except json.JSONDecodeError: + continue + + except Exception as e: + print(f"[ERROR] Reading raw transcript: {e}", file=sys.stderr) + return [] + + # Combine all: assistant messages + thinking blocks + user messages + # Thinking blocks inserted right before their corresponding assistant message + all_messages = [] + + # First, add all messages with their thinking blocks properly ordered + assistant_list = list(assistant_messages.values()) + user_list = list(user_messages.values()) + + # Combine and sort all by timestamp + combined = assistant_list + user_list + combined.sort(key=lambda m: m.get('timestamp', '')) + + # Insert thinking blocks right before their parent assistant message + for msg in combined: + if msg['type'] == 'assistant': + msg_id = msg['message'].get('id') + # If there's a thinking block for this message, insert it first + if msg_id in thinking_blocks: + all_messages.append(thinking_blocks[msg_id]) + all_messages.append(msg) + + return all_messages + +def aggregate_token_usage(messages): + """Aggregate token usage from all assistant messages. + Note: assistant_thinking entries are explicitly excluded to avoid double-counting. + """ + total_usage = { + 'total_input_tokens': 0, + 'total_output_tokens': 0, + 'total_cache_creation_tokens': 0, + 'total_cache_read_tokens': 0, + 'total_ephemeral_5m_tokens': 0, + 'total_ephemeral_1h_tokens': 0, + 'service_tier': None + } + + for msg_data in messages: + # Only count tokens from 'assistant' type, NOT 'assistant_thinking' + # Thinking tokens are already included in the parent assistant message's output_tokens + if msg_data['type'] == 'assistant': + message = msg_data['message'] + usage = message.get('usage', {}) + + if usage: + total_usage['total_input_tokens'] += usage.get('input_tokens', 0) + total_usage['total_output_tokens'] += usage.get('output_tokens', 0) + total_usage['total_cache_creation_tokens'] += usage.get('cache_creation_input_tokens', 0) + total_usage['total_cache_read_tokens'] += usage.get('cache_read_input_tokens', 0) + + cache_creation = usage.get('cache_creation', {}) + total_usage['total_ephemeral_5m_tokens'] += cache_creation.get('ephemeral_5m_input_tokens', 0) + total_usage['total_ephemeral_1h_tokens'] += cache_creation.get('ephemeral_1h_input_tokens', 0) + + if usage.get('service_tier'): + total_usage['service_tier'] = usage.get('service_tier') + + # Add calculated total + total_usage['total_actual_input_tokens'] = ( + total_usage['total_input_tokens'] + + total_usage['total_cache_creation_tokens'] + + total_usage['total_cache_read_tokens'] + ) + + return total_usage + +def analyze_tool_calls(messages): + """Extract tool call metrics from messages.""" + tool_calls = defaultdict(int) + tool_results = defaultdict(int) + + for msg_data in messages: + # Skip entries without 'message' key (e.g., assistant_thinking) + if 'message' not in msg_data: + continue + message = msg_data['message'] + content = message.get('content', []) + + if not isinstance(content, list): + continue + + for item in content: + if not isinstance(item, dict): + continue + + if item.get('type') == 'tool_use': + tool_name = item.get('name', 'unknown') + tool_calls[tool_name] += 1 + + elif item.get('type') == 'tool_result': + # Try to infer tool name from context (simplified) + tool_results['total'] += 1 + + return { + 'tool_calls_by_type': dict(tool_calls), + 'total_tool_calls': sum(tool_calls.values()), + 'total_tool_results': tool_results.get('total', 0) + } + +def analyze_thinking_usage(messages, transcript_path): + """Analyze thinking mode usage in messages.""" + thinking_stats = { + 'thinking_enabled_turns': 0, + 'thinking_disabled_turns': 0, + 'assistant_with_thinking_blocks': 0, + 'thinking_levels': defaultdict(int) + } + + # Track which turns had thinking enabled (from user thinkingMetadata) + for msg_data in messages: + if msg_data['type'] == 'user' and 'thinkingMetadata' in msg_data: + metadata = msg_data['thinkingMetadata'] + if not metadata.get('disabled', True): + thinking_stats['thinking_enabled_turns'] += 1 + level = metadata.get('level', 'none') + thinking_stats['thinking_levels'][level] += 1 + else: + thinking_stats['thinking_disabled_turns'] += 1 + + # Count assistant messages with thinking blocks + # Check ALL occurrences in raw transcript (not just final deduplicated state) + assistant_msg_ids_with_thinking = set() + + try: + if os.path.exists(transcript_path): + with open(transcript_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + if event.get('type') == 'assistant': + message = event.get('message', {}) + msg_id = message.get('id') + content = message.get('content', []) + + if msg_id and isinstance(content, list): + # Check if this occurrence has thinking + has_thinking = any( + isinstance(item, dict) and item.get('type') == 'thinking' + for item in content + ) + if has_thinking: + assistant_msg_ids_with_thinking.add(msg_id) + except: + continue + except Exception: + pass + + thinking_stats['assistant_with_thinking_blocks'] = len(assistant_msg_ids_with_thinking) + + return { + 'thinking_enabled_turns': thinking_stats['thinking_enabled_turns'], + 'thinking_disabled_turns': thinking_stats['thinking_disabled_turns'], + 'assistant_with_thinking_blocks': thinking_stats['assistant_with_thinking_blocks'], + 'thinking_levels': dict(thinking_stats['thinking_levels']) + } + +def calculate_git_metrics(cwd, base_commit): + """Calculate git metrics from diff.""" + try: + original_cwd = os.getcwd() + os.chdir(cwd) + + if not base_commit: + os.chdir(original_cwd) + return {} + + # Add untracked files + excluded_patterns = ['.claude/', '__pycache__/', 'node_modules/', '.mypy_cache/', + '.pytest_cache/', '.DS_Store', '.vscode/', '.idea/'] + + untracked_result = subprocess.run( + ['git', 'ls-files', '--others', '--exclude-standard'], + capture_output=True, text=True, timeout=30 + ) + + if untracked_result.returncode == 0 and untracked_result.stdout.strip(): + untracked_files = [ + f.strip() for f in untracked_result.stdout.strip().split('\n') + if f.strip() and not any(pattern in f for pattern in excluded_patterns) + ] + + for file in untracked_files: + subprocess.run(['git', 'add', '-N', file], capture_output=True, timeout=5) + + # Calculate numstat + result = subprocess.run( + ['git', 'diff', '--numstat', base_commit, '--', '.', + ':!.claude', ':!**/.mypy_cache', ':!**/__pycache__', ':!**/.pytest_cache', + ':!**/.DS_Store', ':!**/node_modules', ':!**/.vscode', ':!**/.idea'], + capture_output=True, text=True, timeout=30 + ) + + os.chdir(original_cwd) + + if result.returncode != 0: + return {} + + lines = result.stdout.strip().split('\n') if result.stdout.strip() else [] + files_changed = 0 + total_lines_changed = 0 + + for line in lines: + if line.strip(): + parts = line.split('\t') + if len(parts) >= 3: + try: + added = int(parts[0]) if parts[0] != '-' else 0 + removed = int(parts[1]) if parts[1] != '-' else 0 + files_changed += 1 + total_lines_changed += added + removed + except ValueError: + continue + + return { + "files_changed_count": files_changed, + "lines_of_code_changed_count": total_lines_changed + } + + except Exception as e: + print(f"Warning: Could not calculate git metrics: {e}", file=sys.stderr) + if 'original_cwd' in locals(): + os.chdir(original_cwd) + return {} + +def copy_raw_transcript(transcript_path, session_id, cwd): + """Copy raw transcript to logs folder.""" + try: + source_path = Path(transcript_path) + if not source_path.exists(): + print(f"Warning: Raw transcript not found at {source_path}", file=sys.stderr) + return False + + model_lane = detect_model_lane(cwd) + experiment_root = get_experiment_root(cwd) + + if model_lane and experiment_root: + logs_dir = Path(experiment_root) / "logs" / model_lane + logs_dir.mkdir(parents=True, exist_ok=True) + dest_path = logs_dir / f"session_{session_id}_raw.jsonl" + else: + project_dir = os.environ.get('CLAUDE_PROJECT_DIR', os.getcwd()) + logs_dir = Path(project_dir) / "logs" + logs_dir.mkdir(exist_ok=True) + dest_path = logs_dir / f"session_{session_id}_raw.jsonl" + + shutil.copy2(source_path, dest_path) + print(f"[OK] Copied raw transcript to {dest_path}") + return True + + except Exception as e: + print(f"[ERROR] Copying raw transcript: {e}", file=sys.stderr) + return False + +def get_base_commit_from_log(log_file): + """Extract base commit from session_start event.""" + try: + if not os.path.exists(log_file): + return None + + with open(log_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line: + try: + event = json.loads(line) + if event.get('type') == 'session_start': + git_metadata = event.get('git_metadata', {}) + return git_metadata.get('base_commit') + except json.JSONDecodeError: + continue + return None + except Exception: + return None + +def main(): + try: + if len(sys.argv) < 2: + print("Usage: process_transcript.py [incremental|final]", file=sys.stderr) + sys.exit(1) + + mode = sys.argv[1].lower() + if mode not in ["incremental", "final"]: + print("Mode must be 'incremental' or 'final'", file=sys.stderr) + sys.exit(1) + + input_data = json.load(sys.stdin) + + session_id = input_data.get("session_id", "unknown") + transcript_path = input_data.get("transcript_path", "") + cwd = input_data.get("cwd", "") + + log_file = get_log_file_path(session_id, cwd) + + if mode == "incremental": + # Stop event: incremental processing (fault tolerance) + messages = read_and_process_raw_transcript(transcript_path) + + if not messages: + return + + # Append all new unique messages to log + # Track what we've already logged (assistant by msg_id, thinking by msg_id, user by uuid) + existing_assistant_ids = set() + existing_thinking_ids = set() + existing_user_uuids = set() + + if os.path.exists(log_file): + with open(log_file, 'r', encoding='utf-8') as f: + for line in f: + try: + event = json.loads(line) + event_type = event.get('type') + if event_type == 'assistant': + msg = event.get('message', {}) + if msg.get('id'): + existing_assistant_ids.add(msg['id']) + elif event_type == 'assistant_thinking': + msg_id = event.get('message_id') + if msg_id: + existing_thinking_ids.add(msg_id) + elif event_type == 'user': + uuid = event.get('uuid') + if uuid: + existing_user_uuids.add(uuid) + except: + continue + + # Append new messages + new_count = 0 + with open(log_file, "a", encoding="utf-8") as f: + for msg_data in messages: + # Check if this is a new message + is_new = False + if msg_data['type'] == 'assistant': + msg_id = msg_data['message'].get('id') + if msg_id and msg_id not in existing_assistant_ids: + is_new = True + existing_assistant_ids.add(msg_id) + elif msg_data['type'] == 'assistant_thinking': + msg_id = msg_data.get('message_id') + if msg_id and msg_id not in existing_thinking_ids: + is_new = True + existing_thinking_ids.add(msg_id) + elif msg_data['type'] == 'user': + uuid = msg_data.get('uuid') + if uuid and uuid not in existing_user_uuids: + is_new = True + existing_user_uuids.add(uuid) + + if is_new: + # Add A/B metadata + log_entry = add_ab_metadata(msg_data.copy(), cwd) + f.write(json.dumps(log_entry) + "\n") + new_count += 1 + + if new_count > 0: + print(f"[OK] Processed {new_count} new messages (total: {len(messages)} unique)") + + elif mode == "final": + # SessionEnd: complete processing + summary + + # Step 1: Copy raw transcript + copy_raw_transcript(transcript_path, session_id, cwd) + + # Step 2: Process complete raw transcript + messages = read_and_process_raw_transcript(transcript_path) + + if not messages: + print("Warning: No messages found in raw transcript", file=sys.stderr) + return + + # Step 3: REBUILD processed log in perfect chronological order + # Read existing non-message events (session_start, etc.) + non_message_events = [] + + if os.path.exists(log_file): + with open(log_file, 'r', encoding='utf-8') as f: + for line in f: + try: + event = json.loads(line) + # Keep session_start and other non-message events + # Exclude assistant, assistant_thinking, and user messages (they come from raw transcript) + if event.get('type') not in ['assistant', 'assistant_thinking', 'user']: + non_message_events.append(event) + except: + continue + + # Combine all events and sort by timestamp + session_start = [e for e in non_message_events if e.get('type') == 'session_start'] + other_events = [e for e in non_message_events if e.get('type') != 'session_start'] + + # Build chronological list: session_start first, then messages sorted by time + all_events = [] + + # Add session_start first (if exists) + if session_start: + all_events.extend(session_start) + + # Add all messages (already sorted by timestamp from read_and_process_raw_transcript) + # Messages already include thinking blocks inserted before their parent assistant message + for msg_data in messages: + all_events.append(add_ab_metadata(msg_data.copy(), cwd)) + + print(f"[OK] Rebuilding log with {len(all_events)} events in chronological order") + + # Step 4: Generate session summary + usage_totals = aggregate_token_usage(messages) + tool_metrics = analyze_tool_calls(messages) + thinking_metrics = analyze_thinking_usage(messages, transcript_path) + + # Calculate duration + timestamps = [ + datetime.fromisoformat(msg['timestamp'].replace('Z', '+00:00')) + for msg in messages if msg.get('timestamp') + ] + + total_duration = 0 + if len(timestamps) >= 2: + duration = max(timestamps) - min(timestamps) + total_duration = duration.total_seconds() + + # Count messages with proper categorization + # Note: assistant_thinking is NOT counted as a separate message (it's part of assistant message) + assistant_count = sum(1 for m in messages if m['type'] == 'assistant') + thinking_count = sum(1 for m in messages if m['type'] == 'assistant_thinking') + + # Categorize user messages + user_prompts = 0 + tool_results = 0 + system_messages = 0 + + for m in messages: + if m['type'] == 'user': + message_content = m['message'].get('content', '') + + # Check if it's a system/meta message + if m.get('isMeta'): + system_messages += 1 + # Check if it's a tool result + elif isinstance(message_content, list): + has_tool_result = any( + isinstance(item, dict) and item.get('type') == 'tool_result' + for item in message_content + ) + if has_tool_result: + tool_results += 1 + else: + user_prompts += 1 + # Check if it's an exit/system command + elif isinstance(message_content, str) and ( + '' in message_content or + '' in message_content + ): + system_messages += 1 + # Real user prompt (string content, not system) + elif isinstance(message_content, str): + user_prompts += 1 + else: + user_prompts += 1 # Default to user prompt + + total_user_events = user_prompts + tool_results + system_messages + + # Calculate actual total messages (excluding thinking blocks as they're not separate messages) + actual_total_messages = assistant_count + total_user_events + + # Get git metrics + base_commit = get_base_commit_from_log(log_file) + git_metrics = calculate_git_metrics(cwd, base_commit) if base_commit else {} + + # Create session summary + model_lane = detect_model_lane(cwd) + + summary = { + "type": "session_summary", + "timestamp": datetime.now(timezone.utc).isoformat(), + "session_id": session_id, + "transcript_path": transcript_path, + "cwd": cwd, + "summary_data": { + "total_duration_seconds": round(total_duration, 2), + "total_messages": actual_total_messages, + "assistant_messages": assistant_count, + "user_prompts": user_prompts, + "user_metrics": { + "user_prompts": user_prompts, + "tool_results": tool_results, + "system_messages": system_messages, + "total_user_events": total_user_events + }, + "usage_totals": usage_totals, + "tool_metrics": tool_metrics, + "thinking_metrics": { + **thinking_metrics, + "assistant_thinking_blocks_captured": thinking_count + }, + "git_metrics": git_metrics, + "files": { + "processed_log": f"session_{session_id}.jsonl", + "raw_transcript": f"session_{session_id}_raw.jsonl", + "git_diff": f"{model_lane}_diff.patch" if model_lane else None + }, + "validation": { + "complete": True, + "unique_messages_processed": actual_total_messages, + "thinking_blocks_extracted": thinking_count + } + } + } + + summary = add_ab_metadata(summary, cwd) + + # Add session summary to events + all_events.append(summary) + + # Step 5: Rewrite log file with all events in perfect chronological order + # Write to temp file first, then rename (atomic) + temp_log_file = log_file + ".tmp" + + with open(temp_log_file, "w", encoding="utf-8") as f: + for event in all_events: + f.write(json.dumps(event) + "\n") + + # Atomic rename + os.replace(temp_log_file, log_file) + + print(f"[OK] Rebuilt log with {len(all_events)} events in chronological order") + print(f"[OK] Generated session summary: {actual_total_messages} messages, {assistant_count} assistant, {user_prompts} user prompts") + if thinking_count > 0: + print(f"[OK] Captured {thinking_count} thinking blocks (tokens already included in assistant output)") + print(f"[OK] User breakdown: {user_prompts} prompts, {tool_results} tool results, {system_messages} system") + print(f"[OK] Tokens: {usage_totals['total_actual_input_tokens']:,} input, {usage_totals['total_output_tokens']:,} output") + + except Exception as e: + print(f"[ERROR] Processing transcript: {e}", file=sys.stderr) + sys.exit(1) + +def calculate_git_metrics(cwd, base_commit): + """Calculate git metrics from diff.""" + try: + original_cwd = os.getcwd() + os.chdir(cwd) + + if not base_commit: + os.chdir(original_cwd) + return {} + + # Add untracked files + excluded_patterns = ['.claude/', '__pycache__/', 'node_modules/', '.mypy_cache/', + '.pytest_cache/', '.DS_Store', '.vscode/', '.idea/'] + + untracked_result = subprocess.run( + ['git', 'ls-files', '--others', '--exclude-standard'], + capture_output=True, text=True, timeout=30 + ) + + if untracked_result.returncode == 0 and untracked_result.stdout.strip(): + untracked_files = [ + f.strip() for f in untracked_result.stdout.strip().split('\n') + if f.strip() and not any(pattern in f for pattern in excluded_patterns) + ] + + for file in untracked_files: + subprocess.run(['git', 'add', '-N', file], capture_output=True, timeout=5) + + # Calculate numstat + result = subprocess.run( + ['git', 'diff', '--numstat', base_commit, '--', '.', + ':!.claude', ':!**/.mypy_cache', ':!**/__pycache__', ':!**/.pytest_cache', + ':!**/.DS_Store', ':!**/node_modules', ':!**/.vscode', ':!**/.idea'], + capture_output=True, text=True, timeout=30 + ) + + os.chdir(original_cwd) + + if result.returncode != 0: + return {} + + lines = result.stdout.strip().split('\n') if result.stdout.strip() else [] + files_changed = 0 + total_lines_changed = 0 + + for line in lines: + if line.strip(): + parts = line.split('\t') + if len(parts) >= 3: + try: + added = int(parts[0]) if parts[0] != '-' else 0 + removed = int(parts[1]) if parts[1] != '-' else 0 + files_changed += 1 + total_lines_changed += added + removed + except ValueError: + continue + + return { + "files_changed_count": files_changed, + "lines_of_code_changed_count": total_lines_changed + } + + except Exception as e: + print(f"Warning: Could not calculate git metrics: {e}", file=sys.stderr) + if 'original_cwd' in locals(): + os.chdir(original_cwd) + return {} + +if __name__ == "__main__": + main() + diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1919ff7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,45 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/capture_session_event.py start" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/process_transcript.py incremental" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/process_transcript.py final" + }, + { + "type": "command", + "command": "python .claude/hooks/capture_session_event.py end" + } + ] + } + ] + }, + "model": "Coyote", + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-1234-tap-tap-go-new", + "ANTHROPIC_BASE_URL": "https://mercor-rl--litellm-proxy-serve.modal.run", + "ANTHROPIC_CUSTOM_HEADERS": "X-Session-Meta: task_id=TASK_13244;expert_name=Flavio Espinoza" + }, + "alwaysThinkingEnabled": true +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c4c9945 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#94dcdf", + "activityBar.background": "#94dcdf", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#cc53c7", + "activityBarBadge.foreground": "#15202b", + "commandCenter.border": "#15202b99", + "sash.hoverBorder": "#94dcdf", + "statusBar.background": "#6dcfd3", + "statusBar.foreground": "#15202b", + "statusBarItem.hoverBackground": "#46c2c7", + "statusBarItem.remoteBackground": "#6dcfd3", + "statusBarItem.remoteForeground": "#15202b", + "titleBar.activeBackground": "#6dcfd3", + "titleBar.activeForeground": "#15202b", + "titleBar.inactiveBackground": "#6dcfd399", + "titleBar.inactiveForeground": "#15202b99" + }, + "peacock.color": "#6dcfd3" +} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index b60106d..96a7fc4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,6 +61,7 @@ set(Aquila_HEADERS aquila/ml.h aquila/filter/MelFilter.h aquila/filter/MelFilterBank.h + aquila/functions/TransientDetector.h aquila/ml/DtwPoint.h aquila/ml/Dtw.h aquila/source/SignalSource.h @@ -98,6 +99,7 @@ set(Aquila_HEADERS set(Aquila_SOURCES aquila/filter/MelFilter.cpp aquila/filter/MelFilterBank.cpp + aquila/functions/TransientDetector.cpp aquila/ml/Dtw.cpp aquila/source/SignalSource.cpp aquila/source/Frame.cpp diff --git a/aquila/functions/TransientDetector.cpp b/aquila/functions/TransientDetector.cpp new file mode 100644 index 0000000..e621f08 --- /dev/null +++ b/aquila/functions/TransientDetector.cpp @@ -0,0 +1,213 @@ +/** + * @file TransientDetector.cpp + * + * High-frequency transient (glass break) detector. + * + * This file is part of the Aquila DSP library. + * Aquila is free software, licensed under the MIT/X11 License. A copy of + * the license is provided with the library in the LICENSE file. + * + * @package Aquila + * @version 3.0.0-dev + * @author Zbigniew Siciarz + * @date 2007-2014 + * @license http://www.opensource.org/licenses/mit-license.php MIT + * @since 3.0.0 + */ + +#include "TransientDetector.h" +#include "../transform/FftFactory.h" +#include "../source/SignalSource.h" +#include +#include + +namespace Aquila +{ + const FrequencyType TransientDetector::LOW_FREQUENCY = 10000.0; + const FrequencyType TransientDetector::HIGH_FREQUENCY = 15000.0; + const double TransientDetector::DETECTION_RATIO = 3.0; + + /** + * Builds the detector and pre-allocates all working memory. + * + * The Hann window, the windowed-frame scratch space and the history + * ring are all sized here and never touched again. The FFT bin range + * for the 10-15 kHz band is resolved up front so the hot path is a + * tight integer loop with no floating-point frequency maths. + * + * @param sampleFrequency sample frequency of incoming audio in Hz + * @param fftSize STFT frame length in samples (power of 2) + * @param historyLength number of past frames in the rolling average + */ + TransientDetector::TransientDetector(FrequencyType sampleFrequency, + std::size_t fftSize, + std::size_t historyLength): + m_sampleFrequency(sampleFrequency), + m_fftSize(fftSize), + m_lowBin(0), + m_highBin(0), + m_fft(FftFactory::getFft(fftSize)), + m_window(fftSize, 0.0), + m_frame(fftSize, 0.0), + m_history(historyLength, 0.0), + m_historyHead(0), + m_historyFill(0), + m_historySum(0.0), + m_lastEnergy(0.0) + { + // Hann window: w[n] = 0.5 * (1 - cos(2*pi*n / (N-1))) + for (std::size_t n = 0; n < m_fftSize; ++n) + { + m_window[n] = 0.5 * (1.0 - + std::cos(2.0 * M_PI * n / static_cast(m_fftSize - 1))); + } + + // Map band edges to FFT bins. Real-input FFT has unique content + // only up to N/2, so clamp the high edge there. + const std::size_t nyquistBin = m_fftSize / 2; + m_lowBin = static_cast( + LOW_FREQUENCY * m_fftSize / m_sampleFrequency + 0.5); + m_highBin = static_cast( + HIGH_FREQUENCY * m_fftSize / m_sampleFrequency + 0.5); + m_lowBin = std::min(m_lowBin, nyquistBin); + m_highBin = std::min(m_highBin, nyquistBin); + } + + /** + * Runs the STFT over an audio buffer and checks for a transient. + * + * @param source audio buffer (at least fftSize samples) + * @return true if any frame in the buffer exceeded the threshold + */ + bool TransientDetector::process(const SignalSource& source) + { + const SampleType* data = source.toArray(); + const std::size_t length = source.getSamplesCount(); + + bool detected = false; + m_lastEnergy = 0.0; + + // Walk the buffer in non-overlapping STFT frames. Any partial + // tail shorter than one frame is ignored rather than zero-padded + // so we never touch memory we did not pre-allocate. + std::size_t offset = 0; + while (offset + m_fftSize <= length) + { + double energy = frameBandEnergy(data + offset); + + if (energy > m_lastEnergy) + { + m_lastEnergy = energy; + } + + // Compare against the rolling background before this frame + // joins it. Require at least one historical frame so the + // very first buffer after power-up cannot fire. + if (m_historyFill > 0) + { + double background = m_historySum / + static_cast(m_historyFill); + if (energy > DETECTION_RATIO * background) + { + detected = true; + } + } + + pushHistory(energy); + offset += m_fftSize; + } + + return detected; + } + + /** + * Returns the current rolling background energy. + * + * @return mean of the history ring buffer, or 0 if still empty + */ + double TransientDetector::getBackgroundEnergy() const + { + if (0 == m_historyFill) + { + return 0.0; + } + return m_historySum / static_cast(m_historyFill); + } + + /** + * Clears the rolling background history. + * + * Does not reallocate; the ring buffer storage is reused. + */ + void TransientDetector::reset() + { + m_historyHead = 0; + m_historyFill = 0; + m_historySum = 0.0; + m_lastEnergy = 0.0; + } + + /** + * Windows one frame, transforms it, and returns band energy. + * + * @param samples pointer to fftSize contiguous input samples + * @return sum of |X[k]|^2 for k in [m_lowBin, m_highBin] + */ + double TransientDetector::frameBandEnergy(const SampleType* samples) + { + // Apply the pre-computed Hann window into the scratch frame. + for (std::size_t n = 0; n < m_fftSize; ++n) + { + m_frame[n] = samples[n] * m_window[n]; + } + + // Forward transform. The FFT engine was planned in the + // constructor; this call only runs the butterfly. + SpectrumType spectrum = m_fft->fft(&m_frame[0]); + + // Integrate squared magnitude across the target bins. Using + // real()*real() + imag()*imag() avoids the sqrt hidden inside + // std::abs(ComplexType) - we want energy, not amplitude. + double energy = 0.0; + for (std::size_t k = m_lowBin; k <= m_highBin; ++k) + { + const ComplexType& c = spectrum[k]; + energy += c.real() * c.real() + c.imag() * c.imag(); + } + + return energy; + } + + /** + * Pushes an energy value into the ring buffer in O(1). + * + * Keeps a running sum so getBackgroundEnergy() never has to loop. + * When the ring wraps, the value being overwritten is subtracted + * from the sum before the new value is added. + * + * @param energy band energy of the frame just processed + */ + void TransientDetector::pushHistory(double energy) + { + const std::size_t capacity = m_history.size(); + + if (m_historyFill < capacity) + { + m_history[m_historyHead] = energy; + m_historySum += energy; + ++m_historyFill; + } + else + { + m_historySum -= m_history[m_historyHead]; + m_history[m_historyHead] = energy; + m_historySum += energy; + } + + ++m_historyHead; + if (m_historyHead >= capacity) + { + m_historyHead = 0; + } + } +} diff --git a/aquila/functions/TransientDetector.h b/aquila/functions/TransientDetector.h new file mode 100644 index 0000000..ecb06df --- /dev/null +++ b/aquila/functions/TransientDetector.h @@ -0,0 +1,195 @@ +/** + * @file TransientDetector.h + * + * High-frequency transient (glass break) detector. + * + * This file is part of the Aquila DSP library. + * Aquila is free software, licensed under the MIT/X11 License. A copy of + * the license is provided with the library in the LICENSE file. + * + * @package Aquila + * @version 3.0.0-dev + * @author Zbigniew Siciarz + * @date 2007-2014 + * @license http://www.opensource.org/licenses/mit-license.php MIT + * @since 3.0.0 + */ + +#ifndef TRANSIENTDETECTOR_H +#define TRANSIENTDETECTOR_H + +#include "../global.h" +#include +#include +#include + +namespace Aquila +{ + class Fft; + class SignalSource; + + /** + * Detects broadband high-frequency transients via STFT band energy. + * + * Glass breaking produces a sharp energy spike in the 10-15 kHz band. + * Each incoming buffer is split into fixed-size frames, windowed, and + * transformed with the shared FFT engine. The energy contained in the + * target band is compared against a rolling average of past frame + * energies; a frame exceeding the average by DETECTION_RATIO is + * reported as a transient. + * + * Every buffer used on the processing path is sized exactly once in + * the constructor. process() performs no heap allocation of its own; + * the one unavoidable allocation is the SpectrumType returned by the + * library FFT interface, which is a contract we inherit unchanged. + */ + class AQUILA_EXPORT TransientDetector + { + public: + /** + * Builds the detector and pre-allocates all working memory. + * + * The FFT engine is obtained once from FftFactory so its internal + * plan / twiddle tables are computed here rather than at run time. + * + * @param sampleFrequency sample frequency of incoming audio in Hz + * @param fftSize STFT frame length in samples (power of 2) + * @param historyLength number of past frames in the rolling average + */ + TransientDetector(FrequencyType sampleFrequency, + std::size_t fftSize = 512, + std::size_t historyLength = 32); + + /** + * Runs the STFT over an audio buffer and checks for a transient. + * + * The buffer is walked in non-overlapping frames of fftSize + * samples. For every complete frame the 10-15 kHz band energy is + * extracted and compared against the current rolling background. + * The frame energy is pushed into the background history after + * the comparison so a spike does not raise its own reference. + * + * @param source audio buffer (at least fftSize samples) + * @return true if any frame in the buffer exceeded the threshold + */ + bool process(const SignalSource& source); + + /** + * Returns the peak band energy seen in the most recent buffer. + * + * @return energy value (sum of squared magnitudes in the band) + */ + double getBandEnergy() const + { + return m_lastEnergy; + } + + /** + * Returns the current rolling background energy. + * + * @return mean of the history ring buffer, or 0 if still empty + */ + double getBackgroundEnergy() const; + + /** + * Clears the rolling background history. + */ + void reset(); + + /** + * Lower edge of the detection band in Hz. + */ + static const FrequencyType LOW_FREQUENCY; + + /** + * Upper edge of the detection band in Hz. + */ + static const FrequencyType HIGH_FREQUENCY; + + /** + * Energy ratio above background that counts as a detection. + */ + static const double DETECTION_RATIO; + + private: + TransientDetector(const TransientDetector&); + const TransientDetector& operator=(const TransientDetector&); + + /** + * Windows one frame, transforms it, and returns band energy. + * + * @param samples pointer to fftSize contiguous input samples + * @return sum of |X[k]|^2 for k in [m_lowBin, m_highBin] + */ + double frameBandEnergy(const SampleType* samples); + + /** + * Pushes an energy value into the ring buffer in O(1). + * + * @param energy band energy of the frame just processed + */ + void pushHistory(double energy); + + /** + * Sample frequency of the audio stream. + */ + FrequencyType m_sampleFrequency; + + /** + * STFT frame length. + */ + std::size_t m_fftSize; + + /** + * First FFT bin inside the detection band (inclusive). + */ + std::size_t m_lowBin; + + /** + * Last FFT bin inside the detection band (inclusive). + */ + std::size_t m_highBin; + + /** + * A shared pointer to FFT algorithm class. + */ + std::shared_ptr m_fft; + + /** + * Pre-computed Hann window coefficients (length fftSize). + */ + std::vector m_window; + + /** + * Scratch space for the windowed frame (length fftSize). + */ + std::vector m_frame; + + /** + * Ring buffer of past frame energies. + */ + std::vector m_history; + + /** + * Write position in the history ring. + */ + std::size_t m_historyHead; + + /** + * How many valid entries the ring currently holds. + */ + std::size_t m_historyFill; + + /** + * Running sum of m_history for O(1) average lookup. + */ + double m_historySum; + + /** + * Peak band energy observed in the last call to process(). + */ + double m_lastEnergy; + }; +} + +#endif // TRANSIENTDETECTOR_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2b8571a..3c8927c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,6 +20,7 @@ set(Aquila_Test_SOURCES Exceptions.cpp filter/MelFilter.cpp filter/MelFilterBank.cpp + functions/TransientDetector.cpp ml/Dtw.cpp source/Frame.cpp source/FramesCollection.cpp diff --git a/tests/functions/TransientDetector.cpp b/tests/functions/TransientDetector.cpp new file mode 100644 index 0000000..9a610ab --- /dev/null +++ b/tests/functions/TransientDetector.cpp @@ -0,0 +1,243 @@ +#include "aquila/global.h" +#include "aquila/functions/TransientDetector.h" +#include "aquila/source/SignalSource.h" +#include "aquila/source/generator/SineGenerator.h" +#include "UnitTest++/UnitTest++.h" +#include +#include + + +SUITE(TransientDetector) +{ + // 44.1 kHz puts the 10-15 kHz band comfortably below Nyquist and + // is the most likely sample rate for the target hardware. + const Aquila::FrequencyType sampleFrequency = 44100.0; + const std::size_t FFT_SIZE = 512; + + // Fills a buffer with a tone at the given frequency. + void fillTone(std::vector& buf, + Aquila::FrequencyType freq, double amplitude) + { + for (std::size_t n = 0; n < buf.size(); ++n) + { + buf[n] = amplitude * + std::sin(2.0 * M_PI * freq * n / sampleFrequency); + } + } + + TEST(BandEdges) + { + CHECK_EQUAL(10000.0, Aquila::TransientDetector::LOW_FREQUENCY); + CHECK_EQUAL(15000.0, Aquila::TransientDetector::HIGH_FREQUENCY); + CHECK_EQUAL(3.0, Aquila::TransientDetector::DETECTION_RATIO); + } + + TEST(NoDetectionOnFirstBuffer) + { + // With an empty history there is no background to compare + // against, so the first buffer must never report a transient + // regardless of its content. + Aquila::TransientDetector detector(sampleFrequency, FFT_SIZE, 8); + + Aquila::SineGenerator gen(sampleFrequency); + gen.setFrequency(12500.0).setAmplitude(1.0).generate(FFT_SIZE); + + CHECK_EQUAL(false, detector.process(gen)); + } + + TEST(NoDetectionOnStationaryNoise) + { + // A constant-energy signal should settle into the background + // and never cross the 300% threshold. + Aquila::TransientDetector detector(sampleFrequency, FFT_SIZE, 8); + + std::vector buf(FFT_SIZE); + fillTone(buf, 12500.0, 0.1); + Aquila::SignalSource source(buf, sampleFrequency); + + for (int i = 0; i < 16; ++i) + { + CHECK_EQUAL(false, detector.process(source)); + } + } + + TEST(DetectsInBandSpike) + { + // Establish a quiet background in-band, then hit it with a + // much louder tone at the same frequency. The band energy + // scales with amplitude squared, so a 10x amplitude jump is + // a 100x energy jump - far past 300%. + Aquila::TransientDetector detector(sampleFrequency, FFT_SIZE, 8); + + std::vector quiet(FFT_SIZE); + fillTone(quiet, 12500.0, 0.05); + Aquila::SignalSource quietSrc(quiet, sampleFrequency); + + for (int i = 0; i < 8; ++i) + { + detector.process(quietSrc); + } + + double background = detector.getBackgroundEnergy(); + CHECK(background > 0.0); + + std::vector spike(FFT_SIZE); + fillTone(spike, 12500.0, 0.5); + Aquila::SignalSource spikeSrc(spike, sampleFrequency); + + CHECK_EQUAL(true, detector.process(spikeSrc)); + CHECK(detector.getBandEnergy() > + Aquila::TransientDetector::DETECTION_RATIO * background); + } + + TEST(IgnoresOutOfBandSpike) + { + // A loud spike well below 10 kHz must leave the band energy + // effectively untouched. Prime the background with a small + // in-band component so the background is non-zero. + Aquila::TransientDetector detector(sampleFrequency, FFT_SIZE, 8); + + std::vector quiet(FFT_SIZE); + fillTone(quiet, 12500.0, 0.05); + Aquila::SignalSource quietSrc(quiet, sampleFrequency); + + for (int i = 0; i < 8; ++i) + { + detector.process(quietSrc); + } + + // 1 kHz spike - same amplitude jump as the positive test. + std::vector spike(FFT_SIZE); + fillTone(spike, 1000.0, 0.5); + Aquila::SignalSource spikeSrc(spike, sampleFrequency); + + CHECK_EQUAL(false, detector.process(spikeSrc)); + } + + TEST(RatioJustBelowThreshold) + { + // Energy ~ amplitude^2. To land just under 3x energy we need + // an amplitude ratio of sqrt(2.9) ~= 1.70. Verify this does + // not fire. + Aquila::TransientDetector detector(sampleFrequency, FFT_SIZE, 8); + + std::vector quiet(FFT_SIZE); + fillTone(quiet, 12500.0, 0.1); + Aquila::SignalSource quietSrc(quiet, sampleFrequency); + + for (int i = 0; i < 8; ++i) + { + detector.process(quietSrc); + } + + std::vector bump(FFT_SIZE); + fillTone(bump, 12500.0, 0.1 * std::sqrt(2.9)); + Aquila::SignalSource bumpSrc(bump, sampleFrequency); + + CHECK_EQUAL(false, detector.process(bumpSrc)); + } + + TEST(RatioJustAboveThreshold) + { + // Amplitude ratio of sqrt(3.1) ~= 1.76 should fire. + Aquila::TransientDetector detector(sampleFrequency, FFT_SIZE, 8); + + std::vector quiet(FFT_SIZE); + fillTone(quiet, 12500.0, 0.1); + Aquila::SignalSource quietSrc(quiet, sampleFrequency); + + for (int i = 0; i < 8; ++i) + { + detector.process(quietSrc); + } + + std::vector bump(FFT_SIZE); + fillTone(bump, 12500.0, 0.1 * std::sqrt(3.1)); + Aquila::SignalSource bumpSrc(bump, sampleFrequency); + + CHECK_EQUAL(true, detector.process(bumpSrc)); + } + + TEST(MultiFrameBuffer) + { + // A buffer longer than one FFT frame is walked in blocks. + // Put the spike in the second half to prove the whole buffer + // is scanned. + Aquila::TransientDetector detector(sampleFrequency, FFT_SIZE, 8); + + std::vector quiet(FFT_SIZE); + fillTone(quiet, 12500.0, 0.05); + Aquila::SignalSource quietSrc(quiet, sampleFrequency); + + for (int i = 0; i < 8; ++i) + { + detector.process(quietSrc); + } + + // Two frames worth: first quiet, second loud. + std::vector big(2 * FFT_SIZE); + for (std::size_t n = 0; n < FFT_SIZE; ++n) + { + double t = static_cast(n) / sampleFrequency; + big[n] = 0.05 * std::sin(2.0 * M_PI * 12500.0 * t); + big[n + FFT_SIZE] = 0.50 * std::sin(2.0 * M_PI * 12500.0 * t); + } + Aquila::SignalSource bigSrc(big, sampleFrequency); + + CHECK_EQUAL(true, detector.process(bigSrc)); + } + + TEST(ShortBufferIgnored) + { + // Buffers shorter than one FFT frame contribute nothing. + Aquila::TransientDetector detector(sampleFrequency, FFT_SIZE, 8); + + std::vector tiny(FFT_SIZE / 2, 0.5); + Aquila::SignalSource tinySrc(tiny, sampleFrequency); + + CHECK_EQUAL(false, detector.process(tinySrc)); + CHECK_EQUAL(0.0, detector.getBandEnergy()); + CHECK_EQUAL(0.0, detector.getBackgroundEnergy()); + } + + TEST(RollingAverageWraps) + { + // With a history of 4, the fifth frame must evict the first. + // Feed four equal-energy frames, read the background, feed a + // fifth equal frame, background must not change. + Aquila::TransientDetector detector(sampleFrequency, FFT_SIZE, 4); + + std::vector buf(FFT_SIZE); + fillTone(buf, 12500.0, 0.1); + Aquila::SignalSource source(buf, sampleFrequency); + + for (int i = 0; i < 4; ++i) + { + detector.process(source); + } + double bgFull = detector.getBackgroundEnergy(); + CHECK(bgFull > 0.0); + + detector.process(source); + CHECK_CLOSE(bgFull, detector.getBackgroundEnergy(), bgFull * 1e-9); + } + + TEST(ResetClearsHistory) + { + Aquila::TransientDetector detector(sampleFrequency, FFT_SIZE, 8); + + std::vector buf(FFT_SIZE); + fillTone(buf, 12500.0, 0.1); + Aquila::SignalSource source(buf, sampleFrequency); + + detector.process(source); + CHECK(detector.getBackgroundEnergy() > 0.0); + + detector.reset(); + CHECK_EQUAL(0.0, detector.getBackgroundEnergy()); + CHECK_EQUAL(0.0, detector.getBandEnergy()); + + // After reset the first-buffer guard is back in effect. + CHECK_EQUAL(false, detector.process(source)); + } +}