← sample-sorts-test-repo

feat(core): Improve type inference and BPM detection

3f7c30b6fe749cbc3184ad113ecfbee04989f739 · ovidiucs · Oct 24, 2025 17:29

This commit addresses a high number of files being incorrectly classified
as "unknown" and fixes bugs where numbers in machine names were
misidentified as BPM values. The changes significantly improve
accuracy, especially for collections like "Samples from Mars".

The primary issues were an incomplete machine token list and overly
simplistic type inference logic. These have been corrected by expanding
the configuration and making the extraction algorithms more robust.

Key improvements:

config.py:
- Expanded `MACHINE_TOKENS` with over 20 new synthesizer and drum
  machine names (e.g., `DX100`, `SH5`, `SYS100M`, `MS10`, `SK1`). This
  prevents numbers in their names from being parsed as BPMs.
- Added more pack-specific rules to `PACK_DEFAULT_TYPE` to provide
  better fallbacks for vendors like `DX100`, `Soviet`, etc.
- Added several specific keywords to `TYPE_MAP` (e.g., `syntom`,
  `pipeorgan`) for more direct type matching.

extractors.py:
- Enhanced `infer_type_from_path()` to tokenize directory names. It
  can now correctly identify the type from folders with prefixes, such as
  "08. Kicks" or "8. Tambourine".
- Updated `infer_type_from_tokens()` to select the *last* matching
  type keyword in a filename, assuming it is the most specific. For
  example, "Synth Tom" is now correctly identified as `tom`, not
  `instrument_synth`.
- Refined `extract_bpm()` to ensure that once a numeric token is
  identified as a BPM, it is consumed and not passed along as a
  descriptor.

+154 −125 · 2 files changed

  • config.py +74 −54
  • extractors.py +80 −71
diff --git a/config.py b/config.py
index accdb2c..d133ec6 100644
--- a/config.py
+++ b/config.py
@@ -34,36 +34,36 @@ TARGET_DIR_MAP: Dict[str, str] = {
     "tom": "ONE-SHOTS/Drum_Hits/Toms",
     "rim": "ONE-SHOTS/Drum_Hits/Rims",
     "cowbell": "ONE-SHOTS/Drum_Hits/Cowbells",
-    
+
     # Cymbals
     "cymbal_crash": "ONE-SHOTS/Cymbals/Crashes",
     "cymbal_ride": "ONE-SHOTS/Cymbals/Rides",
     "cymbal_splash": "ONE-SHOTS/Cymbals/Splashes",
-    
+
     # Percussion
     "perc": "ONE-SHOTS/Percussion/Misc",
-    
+
     # Stabs
     "stab_synth": "ONE-SHOTS/Stabs/Synth",
     "stab_piano": "ONE-SHOTS/Stabs/Piano",
     "stab_guitar": "ONE-SHOTS/Stabs/Guitar",
     "stab_brass": "ONE-SHOTS/Stabs/Brass",
     "stab_string": "ONE-SHOTS/Stabs/Strings",
-    
+
     # Vocals
     "vocalcut": "ONE-SHOTS/Vocals/Cuts",
-    
+
     # FX
     "fx": "ONE-SHOTS/FX/Misc",
     "fx_riser": "ONE-SHOTS/FX/Risers",
     "fx_impact": "ONE-SHOTS/FX/Impacts",
     "fx_noise": "ONE-SHOTS/FX/Noise",
-    
+
     # Bass
     "bass_808": "ONE-SHOTS/Bass/808",
     "bass_synth": "ONE-SHOTS/Bass/Synth",
     "bass_guitar": "ONE-SHOTS/Bass/Guitar",
-    
+
     # ===== Loops =====
     "drumloop": "LOOPS/Drums",
     "toploop": "LOOPS/Drums/Tops",
@@ -73,7 +73,7 @@ TARGET_DIR_MAP: Dict[str, str] = {
     "fxloop": "LOOPS/FX",
     "vocalloop": "LOOPS/Vocals",
     "drum_fill": "LOOPS/Drums/Fills",
-    
+
     # ===== Instruments =====
     "instrument_key": "INSTRUMENTS/Keys",
     "instrument_synth": "INSTRUMENTS/Synths",
@@ -83,7 +83,7 @@ TARGET_DIR_MAP: Dict[str, str] = {
     "instrument_vocal": "INSTRUMENTS/Vocals",
     "instrument_perc": "INSTRUMENTS/Percussion",
     "instrument_guitar": "INSTRUMENTS/Guitars",
-    
+
     # ===== Fallback =====
     "unknown": "UNSORTED/_UnknownType",
 }
@@ -95,39 +95,40 @@ TYPE_MAP: Dict[str, str] = {
     # ===== Drums =====
     "bd": "kick",
     "kick": "kick",
-    
+
     "sd": "snare",
     "snare": "snare",
-    
+
     "ch": "hat_closed",
     "chh": "hat_closed",
     "hh": "hat_closed",
     "hat_closed": "hat_closed",
     "closedhat": "hat_closed",
-    
+
     "oh": "hat_open",
     "ohh": "hat_open",
     "hat_open": "hat_open",
     "openhat": "hat_open",
-    
+
     "rim": "rim",
     "rimshot": "rim",
     "sidestick": "rim",
-    
+
     "clap": "clap",
-    
+
     "tom": "tom",
+    "syntom": "tom",
     "racktom": "tom",
     "floortom": "tom",
-    
+
     "cowbell": "cowbell",
-    
+
     "crash": "cymbal_crash",
     "ride": "cymbal_ride",
     "splash": "cymbal_splash",
     "cymbal": "cymbal_crash",
     "china": "cymbal_crash",
-    
+
     # ===== Percussion =====
     "perc": "perc",
     "percussion": "perc",
@@ -144,6 +145,7 @@ TYPE_MAP: Dict[str, str] = {
     "bell": "perc",
     "gong": "perc",
     "tamb": "perc",
+    "tambo": "perc",
     "tambourine": "perc",
     "cabasa": "perc",
     "quijada": "perc",
@@ -153,11 +155,11 @@ TYPE_MAP: Dict[str, str] = {
     "click": "perc",
     "snap": "perc",
     "wendel": "perc",
-    
+
     # ===== Other Drums =====
     "fill": "drum_fill",
     "drum_fill": "drum_fill",
-    
+
     # ===== Vocals =====
     "vox": "vocalcut",
     "vocal": "vocalcut",
@@ -165,7 +167,7 @@ TYPE_MAP: Dict[str, str] = {
     "voice": "vocalcut",
     "vocalcut": "vocalcut",
     "chant": "vocalcut",
-    
+
     # ===== Stabs =====
     "stab": "stab_synth",  # Default to synth
     "stab_synth": "stab_synth",
@@ -230,7 +232,7 @@ TYPE_MAP: Dict[str, str] = {
     "ninth": "stab_piano",
     "eleventh": "stab_piano",
     "thirteenth": "stab_piano",
-    
+
     # ===== Bass =====
     "bass": "bass_synth",
     "sub": "bass_synth",
@@ -285,7 +287,7 @@ TYPE_MAP: Dict[str, str] = {
     "landing": "fx",
     "space": "fx",
     "alien": "fx",
-    
+
     # ===== Loops =====
     "loop": "drumloop",  # Default to drum loop
     "drumloop": "drumloop",
@@ -375,6 +377,7 @@ TYPE_MAP: Dict[str, str] = {
     "keyed": "instrument_key",
     "keyboard": "instrument_key",
     "organ": "instrument_key",
+    "pipeorgan": "instrument_key",
     "organs": "instrument_key",
     "hammond": "instrument_key",
     "farfisa": "instrument_key",
@@ -411,52 +414,59 @@ TYPE_MAP: Dict[str, str] = {
 # ==================== Machine Tokens ====================
 
 MACHINE_TOKENS: Set[str] = {
-    # Roland TR/MC Series
+    # Roland TR/MC/SH Series
     "101", "202", "303", "505", "606", "707", "808", "909", "727", "626",
-    
+    "sh101", "sh5", "sys100m",
+
     # Oberheim & Linn
     "dmx", "oberheim", "linn", "lm1", "lm2", "lindrum", "linndrum",
-    
+
     # Boss DR Series
     "dr55", "dr550", "dr660", "dr110", "dr202", "dr770", "dr880",
-    
+
     # E-mu & Akai
     "sp1200", "sp12", "sp808", "mpc", "mpc60", "mpc3000", "sp16",
-    
-    # Yamaha RX Series
-    "rx5", "rx7", "rx11", "rx15", "rx21", "rx21l",
-    
-    # Roland R Series
-    "r8", "r70",
-    
+
+    # Yamaha RX/DX Series
+    "rx5", "rx7", "rx11", "rx15", "rx21", "rx21l", "dx7", "d50", "dx100",
+
+    # Roland R/JD/JX Series
+    "r8", "r70", "jd800", "jx3p", "jx8p",
+
+    # Korg
+    "ms10", "ms20", "m1",
+
+    # Casio SK Series
+    "sk1", "sk5",
+
     # Various Drum Machines
     "dd20", "dd30", "dd50", "tempest", "tanzbar", "pulsar", "electribe",
     "volca", "machinedrum", "rytm", "analog", "analogfour", "digitakt",
     "digitone", "drumbrute", "impact",
-    
+
     # Vintage Machines
     "cr78", "cr68", "cr8000", "fr2l", "fr3l", "fr5", "fr6", "fr7", "fr8", "fr9",
     "srm", "ace", "acetone", "rhythm77", "drumulator", "simmons", "simmonssds",
     "sds5", "sds7", "sds8", "sds9",
-    
+
     # Samplers
     "fairlight", "e-mu", "emu", "sp12000", "sp-12", "s612", "s950", "s760",
     "s770", "s5000",
-    
+
     # Synthesizers
-    "dx7", "d50", "jd800", "jx3p", "jx8p", "matrix", "matrix1000", "xpander",
-    "ems", "modular", "moog", "eko", "crumar", "vermona", "doepfer", "cwejman",
-    "buchla", "serge", "arp", "arp2600", "prophet5", "prophet10", "cs80",
-    "minibrute", "luna", "lyra", "lumatone", "polybrute", "matrixbrute",
-    
+    "matrix", "matrix1000", "xpander", "ems", "modular", "moog", "eko", "crumar",
+    "vermona", "doepfer", "cwejman", "buchla", "serge", "arp", "arp2600",
+    "prophet5", "prophet10", "cs80", "minibrute", "luna", "lyra", "lumatone",
+    "polybrute", "matrixbrute", "voyetra",
+
     # Soviet/Eastern Bloc
     "ddd1", "polyvoks", "polivoks", "altair", "estradin", "aelita", "formanta",
-    
+
     # Other Notable Machines
-    "minipops", "mks", "juno", "jp8", "jp6", "jp4", "ms20", "pro-one",
-    "prophet", "odyssey", "mirage", "tb303", "m1", "synare", "viscount",
+    "minipops", "mks", "juno", "jp8", "jp6", "jp4", "pro-one",
+    "prophet", "odyssey", "mirage", "tb303", "synare", "viscount",
     "dr bohm", "bohm", "micromoog", "minimoog", "vp330",
-    
+
     # Common abbreviations
     "quasimidi", "alesis", "boss", "roland", "korg", "yamaha",
 }
@@ -544,19 +554,19 @@ JUNK_WORDS: Set[str] = {
     "various", "repaired", "fix", "fixed", "sample", "samples", "one", "shot",
     "oneshot", "hit", "hits", "drum", "drums", "loop", "loops", "from", "mars",
     "the", "and", "of", "in", "mix", "mixed", "stem", "stems",
-    
+
     # File extensions
     "wav", "aif", "aiff", "flac", "mp3",
-    
+
     # Collection terms
     "kit", "kits", "individual", "hits", "legacy", "snacks", "essential",
     "reserve", "creations", "fragments", "misc", "instrument", "instruments",
     "presets", "pack", "preset", "fragment", "oddities", "oddity", "modern",
     "private",
-    
+
     # Recording terms
     "di", "main", "full", "sfm", "sfm-",
-    
+
     # Pack names (to avoid in descriptors)
     "micro", "mini", "micromoog", "minimoog",
 }
@@ -571,7 +581,15 @@ PACK_DEFAULT_TYPE: Dict[str, str] = {
     "pulsar": "instrument_synth",
     "modular": "instrument_synth",
     "acid": "instrument_synth",
-    
+    "dx100": "instrument_synth",
+    "sh5": "instrument_synth",
+    "soviet": "instrument_synth",
+    "sys100m": "instrument_synth",
+    "ms10": "instrument_synth",
+    "101": "instrument_synth",
+    "voyetra": "instrument_synth",
+
+
     # FX packs
     "found": "fx",
     "databenders": "fx",
@@ -579,17 +597,19 @@ PACK_DEFAULT_TYPE: Dict[str, str] = {
     "fragments": "fx",
     "oddities": "fx",
     "synare": "fx",
-    
+
     # Percussion packs
     "wendel": "perc",
-    
+    "sp1200": "drumloop", # Often associated with drum loops
+
     # Keyboard/Key packs
     "viscount": "instrument_key",
     "360": "instrument_key",
     "mirage": "instrument_key",
     "s612": "instrument_key",
     "s950": "instrument_key",
-    
+    "sk1": "instrument_key",
+
     # String packs
     "arp omni": "instrument_string",
     "vp330": "instrument_string",
diff --git a/extractors.py b/extractors.py
index 54862d1..5c49dde 100644
--- a/extractors.py
+++ b/extractors.py
@@ -16,6 +16,8 @@ from pathlib import Path
 from typing import Dict, List, Optional, Set, Tuple
 
 from config import BPM_MIN, BPM_MAX, INDEX_THRESHOLD
+# Import tokenize to use in path inference
+from text_utils import tokenize
 
 
 # ==================== Regex Patterns ====================
@@ -45,21 +47,21 @@ def extract_bpm(
 ) -> Tuple[Optional[str], List[str]]:
     """
     Extracts BPM from token list, avoiding false positives from indices and machine names.
-    
+
     Strategy:
     1. Identify potential leading index (first token, purely numeric)
     2. Look for explicit 'bpm' suffix (e.g., '120bpm')
     3. Look for standalone numbers in valid BPM range (40-300)
     4. Exclude machine tokens and identified indices
-    
+
     Args:
         tokens: List of filename tokens
         machine_tokens_set: Set of known machine token numbers (808, 303, etc.)
         debug: If True, prints detailed extraction logic
-        
+
     Returns:
         Tuple of (bpm_string or None, remaining_tokens)
-        
+
     Example:
         >>> extract_bpm(['67', 'drumloop', '120', 'kick'], {'808', '909'}, False)
         ('120bpm', ['67', 'drumloop', 'kick'])
@@ -67,10 +69,10 @@ def extract_bpm(
     bpm = None
     consumed_indices = set()
     original_length = len(tokens)
-    
+
     if debug:
         print(f"  [BPM DEBUG] Input tokens: {tokens}")
-    
+
     # ===== STEP 1: Identify leading index =====
     # First token that's purely numeric and < INDEX_THRESHOLD is likely an index
     leading_index = None
@@ -81,7 +83,7 @@ def extract_bpm(
             if debug:
                 print(f"  [BPM DEBUG] Token at position 0 ('{tokens[0]}') "
                       f"identified as potential leading index")
-    
+
     # ===== STEP 2: Pass 1 - Explicit BPM markers =====
     for i, t in enumerate(tokens):
         # Skip the identified leading index
@@ -90,7 +92,7 @@ def extract_bpm(
                 print(f"  [BPM DEBUG] Skipping position {i} ('{t}') - "
                       f"identified as leading index")
             continue
-        
+
         # Check for explicit 'bpm' suffix (e.g., '120bpm')
         if t.endswith('bpm') and t[:-3].isdigit():
             v = int(t[:-3])
@@ -100,7 +102,7 @@ def extract_bpm(
                 if debug:
                     print(f"  [BPM DEBUG] Found explicit BPM at position {i}: {t}")
                 break
-        
+
         # Check for full match of BPM pattern
         m_full = RE_BPM.fullmatch(t)
         if m_full and bpm is None:
@@ -114,7 +116,7 @@ def extract_bpm(
                         print(f"  [BPM DEBUG] Found BPM via fullmatch at "
                               f"position {i}: {bpm}")
                     break
-    
+
     # ===== STEP 3: Pass 2 - Search within tokens =====
     if bpm is None:
         for i, t in enumerate(tokens):
@@ -124,24 +126,24 @@ def extract_bpm(
                     print(f"  [BPM DEBUG] Skipping position {i} ('{t}') - "
                           f"leading index")
                 continue
-            
+
             # Search for BPM pattern within the token
             m_search = RE_BPM.search(t)
             if m_search:
                 num_str = m_search.group(1)
                 v = int(num_str)
-                
+
                 if debug:
                     print(f"  [BPM DEBUG] Found number '{num_str}' at "
                           f"position {i} in token '{t}'")
-                
+
                 # === Validation checks ===
                 # 1. Is it a known machine token?
                 if num_str in machine_tokens_set:
                     if debug:
                         print(f"  [BPM DEBUG]   -> Rejected: matches machine token")
                     continue
-                
+
                 # 2. Is it likely an index (first/last position, purely numeric)?
                 is_first_or_last = (i == 0 or i == original_length - 1)
                 if is_first_or_last and t == num_str:
@@ -149,24 +151,25 @@ def extract_bpm(
                         print(f"  [BPM DEBUG]   -> Rejected: pure number at "
                               f"first/last position (likely index)")
                     continue
-                
+
                 # 3. Is it in valid BPM range?
                 if BPM_MIN <= v <= BPM_MAX:
                     bpm = f"{v}bpm"
+                    consumed_indices.add(i) # Consume the token
                     if debug:
                         print(f"  [BPM DEBUG]   -> Accepted as BPM: {bpm}")
-                    break
+                    break # Stop after finding one
                 elif debug:
                     print(f"  [BPM DEBUG]   -> Rejected: outside BPM range "
                           f"({BPM_MIN}-{BPM_MAX})")
-    
+
     # Return BPM and remaining tokens
     keep = [tokens[i] for i in range(len(tokens)) if i not in consumed_indices]
-    
+
     if debug:
         print(f"  [BPM DEBUG] Final BPM: {bpm}")
         print(f"  [BPM DEBUG] Remaining tokens: {keep}")
-    
+
     return bpm, keep
 
 
@@ -175,21 +178,21 @@ def extract_bpm(
 def extract_key(tokens: List[str]) -> Tuple[Optional[str], List[str]]:
     """
     Extracts musical key from token list.
-    
+
     Looks for:
     - Note names (A-G) with optional sharps/flats
     - Optional octave numbers
     - Minor/Major indicators
-    
+
     Avoids:
     - Bare note+octave combinations (e.g., 'c2', 'a1') that are likely machine names
-    
+
     Args:
         tokens: List of filename tokens
-        
+
     Returns:
         Tuple of (key_string or None, remaining_tokens)
-        
+
     Example:
         >>> extract_key(['loop', 'cmaj', '120bpm'])
         ('cmaj', ['loop', '120bpm'])
@@ -198,23 +201,23 @@ def extract_key(tokens: List[str]) -> Tuple[Optional[str], List[str]]:
     """
     key = None
     found_note_data = None
-    
+
     # Check for minor/major indicators
     is_minor = any(RE_MINOR_WORD.search(t) for t in tokens)
     is_major = any(RE_MAJOR_WORD.search(t) for t in tokens)
-    
+
     original_tokens = list(tokens)
     kept_indices = set(range(len(tokens)))
-    
+
     # Search for note patterns
     for i, t in enumerate(original_tokens):
         if i not in kept_indices:
             continue
-            
+
         # Skip suspect keys (bare note+octave)
         if t in SUSPECT_KEYS:
             continue
-        
+
         # Try to match note pattern
         m = RE_NOTE_TOKEN.search(t)
         if m:
@@ -222,21 +225,21 @@ def extract_key(tokens: List[str]) -> Tuple[Optional[str], List[str]]:
             octave_part = m.group(3)
             minor_flag = m.group(4)
             matched_text = m.group(0)
-            
+
             # Check if this is an ambiguous match
             is_ambiguous_match = (
                 matched_text in SUSPECT_KEYS or
-                (len(note_match_str) == 1 and octave_part and 
+                (len(note_match_str) == 1 and octave_part and
                  not m.group(2) and not minor_flag)
             )
-            
+
             if is_ambiguous_match:
                 # Skip if the ambiguous part is the whole token
                 if matched_text == t:
                     continue
                 # Also skip if ambiguous within a larger token (conservative)
                 continue
-            
+
             # Keep the best match (prefer more specific matches)
             if found_note_data is None:
                 found_note_data = (note_match_str, i, matched_text)
@@ -245,7 +248,7 @@ def extract_key(tokens: List[str]) -> Tuple[Optional[str], List[str]]:
                 prev_note, prev_i, prev_matched = found_note_data
                 if len(note_match_str) > len(prev_note) or octave_part:
                     found_note_data = (note_match_str, i, matched_text)
-    
+
     # Construct key string
     if found_note_data:
         note_str, idx, matched = found_note_data
@@ -256,7 +259,7 @@ def extract_key(tokens: List[str]) -> Tuple[Optional[str], List[str]]:
         else:
             key = note_str
         kept_indices.discard(idx)
-    
+
     # Return key and remaining tokens
     remaining = [original_tokens[i] for i in sorted(kept_indices)]
     return key, remaining
@@ -267,16 +270,16 @@ def extract_key(tokens: List[str]) -> Tuple[Optional[str], List[str]]:
 def extract_index(tokens: List[str]) -> Tuple[Optional[str], List[str]]:
     """
     Extracts the leading index from tokens.
-    
+
     The index is typically the first token if it's purely numeric.
     Common in sample packs (e.g., '001_Kick.wav', '67_Loop.wav').
-    
+
     Args:
         tokens: List of filename tokens
-        
+
     Returns:
         Tuple of (index_string or None, remaining_tokens)
-        
+
     Example:
         >>> extract_index(['67', 'kick', '808'])
         ('67', ['kick', '808'])
@@ -297,50 +300,52 @@ def infer_type_from_tokens(
 ) -> Tuple[str, List[str]]:
     """
     Infers file type from tokens using the type mapping.
-    
+
     Strategy:
-    1. Look for explicit type tokens in the type_map
-    2. Fall back to pack-specific defaults
-    3. Apply heuristics (e.g., synth keywords -> instrument_synth)
-    4. Default to 'unknown'
-    
+    1. Look for all explicit type tokens in the type_map.
+    2. The last one found is considered the most specific (e.g., in "Synth Bass", "bass" is last).
+    3. Fall back to pack-specific defaults.
+    4. Apply heuristics (e.g., synth keywords -> instrument_synth).
+    5. Default to 'unknown'.
+
     Args:
         tokens: List of filename tokens
         type_map: Dictionary mapping tokens to type strings
         pack_default: Optional default type for this pack
-        
+
     Returns:
         Tuple of (type_string, remaining_tokens_as_descriptors)
-        
+
     Example:
-        >>> infer_type_from_tokens(['kick', 'heavy', 'distorted'], 
-        ...                        {'kick': 'kick'}, None)
-        ('kick', ['heavy', 'distorted'])
+        >>> infer_type_from_tokens(['synth', 'bass', 'shot'],
+        ...                        {'synth': 'instrument_synth', 'bass': 'bass_synth'}, None)
+        ('bass_synth', ['shot'])
     """
     matched_type = None
     consumed_indices = set()
-    
-    # Pass 1: Look for explicit type tokens
+
+    # Pass 1: Find the most specific (last) type token
     for i, t in enumerate(tokens):
         t_lower = t.lower().strip()
         if t_lower in type_map:
+            # Later matches override earlier ones, assuming more specific types come later
             matched_type = type_map[t_lower]
-            consumed_indices.add(i)
-            break  # Use first match
-    
-    # Pass 2: Fall back to pack default
+            # Clear previous consumed indices and add the current one
+            consumed_indices = {i}
+
+    # Pass 2: Fall back to pack default if no token match
     if matched_type is None and pack_default:
         matched_type = pack_default
-    
+
     # Pass 3: Heuristic - aggressive synth inference
     if matched_type is None:
         synth_keywords = {
-            "synth", "analog", "digital", "pad", "lead", "bass",
+            "synth", "analog", "digital", "pad", "lead",
             "saw", "square", "pulse"
         }
         if any(t.lower() in synth_keywords for t in tokens):
             matched_type = "instrument_synth"
-    
+
     # Return type and remaining tokens as descriptors
     remaining = [tokens[i] for i in range(len(tokens)) if i not in consumed_indices]
     return matched_type or "unknown", remaining
@@ -349,25 +354,29 @@ def infer_type_from_tokens(
 def infer_type_from_path(rel_path: Path, type_map: Dict[str, str]) -> Optional[str]:
     """
     Infers file type from directory names in the path.
-    
+
     Useful as a fallback when filename doesn't contain type information.
-    
+    This version tokenizes path parts to find keywords.
+
     Args:
         rel_path: Relative path from source root
         type_map: Dictionary mapping tokens to type strings
-        
+
     Returns:
         Type string or None if no match found
-        
+
     Example:
         >>> from pathlib import Path
-        >>> infer_type_from_path(Path('Drums/Kicks/sample.wav'), {'kicks': 'kick'})
+        >>> infer_type_from_path(Path('Drums/8. Kicks/sample.wav'), {'kicks': 'kick'})
         'kick'
     """
-    for part in rel_path.parts:
-        part_lower = part.lower().strip()
-        if part_lower in type_map:
-            return type_map[part_lower]
+    # Iterate backwards from the file's parent directory for more specific context
+    for part in reversed(rel_path.parts[:-1]):
+        # Tokenize the directory name to handle cases like "8. Kicks"
+        part_tokens = tokenize(part)
+        for token in part_tokens:
+            if token in type_map:
+                return type_map[token]
     return None
 
 
@@ -376,14 +385,14 @@ def infer_type_from_path(rel_path: Path, type_map: Dict[str, str]) -> Optional[s
 def extract_vendor_from_path(rel_path: Path, normalize_func) -> str:
     """
     Extracts vendor name from the first directory in the path.
-    
+
     Args:
         rel_path: Relative path from source root
         normalize_func: Function to normalize the vendor name
-        
+
     Returns:
         Normalized vendor string
-        
+
     Example:
         >>> from pathlib import Path
         >>> extract_vendor_from_path(Path('VP330 From Mars/Loops/loop.wav'),