detect_key
Identifies musical keys in audio to support pattern creation and music generation within the Strudel.cc environment.
Instructions
Key detection
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
No arguments | |||
Implementation Reference
- src/server/EnhancedMCPServerFixed.ts:286-290 (registration)MCP tool registration defining the 'detect_key' tool with name, description, and empty input schema (no parameters required).{ name: 'detect_key', description: 'Key detection', inputSchema: { type: 'object', properties: {} } },
- MCP server handler for 'detect_key' tool: checks initialization, calls controller.detectKey(), processes KeyAnalysis result into standardized output with confidence, alternatives, and error handling.case 'detect_key': if (!this.isInitialized) { return 'Browser not initialized. Run init first.'; } try { const keyAnalysis = await this.controller.detectKey(); if (!keyAnalysis || keyAnalysis.confidence < 0.1) { return { key: 'Unknown', scale: 'unknown', confidence: 0, message: 'No clear key detected. Ensure audio is playing and has tonal content.' }; } const result: any = { key: keyAnalysis.key, scale: keyAnalysis.scale, confidence: Math.round(keyAnalysis.confidence * 100) / 100, message: `Detected ${keyAnalysis.key} ${keyAnalysis.scale} with ${Math.round(keyAnalysis.confidence * 100)}% confidence` }; // Include alternatives if available and confidence is moderate if (keyAnalysis.alternatives && keyAnalysis.alternatives.length > 0) { result.alternatives = keyAnalysis.alternatives.map((alt: any) => ({ key: alt.key, scale: alt.scale, confidence: Math.round(alt.confidence * 100) / 100 })); } return result; } catch (error: any) { return { key: 'Unknown', scale: 'unknown', confidence: 0, error: error.message || 'Key detection failed' }; }
- src/StrudelController.ts:280-284 (helper)Delegation helper in StrudelController: forwards detectKey call to AudioAnalyzer instance with the browser page.async detectKey(): Promise<KeyAnalysis | null> { if (!this._page) throw new Error('Browser not initialized. Run init tool first.'); return await this.analyzer.detectKey(this._page); }
- src/AudioAnalyzer.ts:487-600 (handler)Core key detection handler: extracts chroma vector from audio analyzer data, correlates with scale profiles using cosine similarity and Krumhansl-Schmuckler algorithm, applies tonal biases, computes confidence and alternatives.async detectKey(page: Page): Promise<KeyAnalysis | null> { // Get analyzer object from browser const analyzer = await page.evaluate(() => { return (window as any).strudelAudioAnalyzer; }); if (!analyzer || !analyzer.isConnected) { throw new Error('Audio analyzer not connected'); } let chroma: number[]; // Check if this is a mock with pre-calculated chroma vector (for testing) if (typeof analyzer.analyze === 'function') { const analysis = analyzer.analyze(); if (analysis?.features?.chromaVector) { chroma = analysis.features.chromaVector; } else { // No mock data, extract from FFT if (!analyzer.dataArray) { throw new Error('Invalid audio data'); } const fftData = new Uint8Array(analyzer.dataArray); chroma = this.extractChroma(fftData); } } else { // No analyze function, extract from FFT if (!analyzer.dataArray) { throw new Error('Invalid audio data'); } const fftData = new Uint8Array(analyzer.dataArray); chroma = this.extractChroma(fftData); } // Check for sufficient energy const totalEnergy = chroma.reduce((sum, val) => sum + val, 0); if (totalEnergy < 0.1) { return { key: 'C', scale: 'major', confidence: 0.1 }; } // Correlate with all key/scale combinations const scores: Array<{ key: string; scale: string; score: number }> = []; for (const scale of Object.keys(this.SCALE_PROFILES)) { // Normalize profile to sum to 1 const rawProfile = this.SCALE_PROFILES[scale]; const profileSum = rawProfile.reduce((a, b) => a + b, 0); const profile = rawProfile.map(v => v / profileSum); for (let tonic = 0; tonic < 12; tonic++) { // Rotate chroma to align with profile // Put the tonic at position 0 to match the profile structure const rotatedChroma = new Array(12); for (let i = 0; i < 12; i++) { rotatedChroma[i] = chroma[(i + tonic) % 12]; } // Use cosine similarity for correlation const correlation = this.cosineSimilarity(rotatedChroma, profile); scores.push({ key: this.PITCH_CLASSES[tonic], scale, score: correlation }); } } // Find the top 3 loudest pitch classes - any could be the tonic const chromaWithIndices = chroma.map((v, i) => ({ value: v, index: i })); chromaWithIndices.sort((a, b) => b.value - a.value); const topPitches = chromaWithIndices.slice(0, 3).map(x => this.PITCH_CLASSES[x.index]); // Apply bias boosts to resolve ambiguous cases for (const s of scores) { // Boost keys that match one of the top 3 loudest pitches // (any of these could plausibly be the tonic) const pitchBoost = topPitches.indexOf(s.key); if (pitchBoost >= 0) { // Slightly favor 2nd pitch to handle dominant/mediant being louder than tonic const boosts = [1.075, 1.075, 1.075]; s.score *= boosts[pitchBoost]; } // Boost for common scales if (s.scale === 'major') { s.score *= 1.03; // 3% boost for major scales (most common) } else if (s.scale === 'dorian') { s.score *= 1.015; // 1.5% boost for dorian (common modal scale) } } // Sort by score (after applying biases) scores.sort((a, b) => b.score - a.score); // Calculate confidence const best = scores[0]; const secondBest = scores[1]; // Confidence based on score strength (cosine similarity 0-1) and separation // Increased separation weight to better differentiate close matches const strength = best.score; const separation = Math.min(1, Math.max(0, (best.score - secondBest.score) * 10)); const confidence = Math.min(1, strength * 0.75 + separation * 0.25); return { key: best.key, scale: best.scale as any, confidence, alternatives: scores.slice(1, 4).map(s => ({ key: s.key, scale: s.scale, confidence: Math.max(0, s.score) })) }; }