gps-music.py play random music based on GPS lat/lon
Discovered: Nov 7, 2026 15:41 (UTC) gps-music.py play random music based on GPS lat/lon <– A Python script for Pythonista 3 on iOS that reads the device’s GPS coordinates and randomly plays .aif / .aiff audio files every 2 seconds based on your physical location.
- Just like July 6, 2010 gpsgamelan.py is ok, but i want real music - use 12 major keys with 10 chords but better :-)
- has all the usual pythonista problems like if the app is backgrounded, it stops runnng. Maybe a Swift playground app next to work around this?
Python code for: gps_music.py
Because my blog will last longer than github :-) herewith the code for gps_music.py
#!/usr/bin/env python3
"""
gps_music.py - Location-based ambient audio playback for Pythonista 3.
Runs an event loop that, once every 2 seconds, fetches the device's GPS
coordinates and randomly plays an .aif/.aiff audio file from a designated
directory based on the current location.
"""
from __future__ import annotations
import argparse
import collections
import hashlib
import math
import os
import random
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple
# ==============================================================================
# Default Configuration (can be edited directly or overridden via CLI arguments)
# ==============================================================================
DEFAULT_AUDIO_DIR = "sample_audio" # Directory containing .aif / .aiff files
DEFAULT_INTERVAL = 2.0 # Interval in seconds between plays
DEFAULT_GRID_SIZE_METERS = 50.0 # Spatial grid cell size in meters (~50m)
DEFAULT_PALETTE_SIZE = 4 # Number of audio files per spatial cell pool
DEFAULT_VOLUME = 1.0 # Volume from 0.0 to 1.0
AUDIO_EXTENSIONS = (".aif", ".aiff", ".AIF", ".AIFF")
# ==============================================================================
# Platform Detection & Native Modules
# ==============================================================================
try:
import location # Pythonista 3 built-in module
HAS_PYTHONISTA_LOCATION = True
except ImportError:
location = None
HAS_PYTHONISTA_LOCATION = False
try:
import sound # Pythonista 3 built-in module
HAS_PYTHONISTA_SOUND = True
except ImportError:
sound = None
HAS_PYTHONISTA_SOUND = False
def find_audio_files(directory: str ¦ Path, extensions: Sequence[str] = AUDIO_EXTENSIONS) -> List[Path]:
"""Scan directory recursively for files matching the given audio extensions."""
base_path = Path(directory).expanduser().resolve()
if not base_path.exists():
raise FileNotFoundError(f"Audio directory does not exist: {base_path}")
if not base_path.is_dir():
raise NotADirectoryError(f"Audio path is not a directory: {base_path}")
ext_set = {ext.lower() for ext in extensions}
audio_files = [
path for path in base_path.rglob("*")
if path.is_file() and path.suffix.lower() in ext_set
]
audio_files.sort()
return audio_files
def coord_to_grid(lat: float, lon: float, grid_size_meters: float = DEFAULT_GRID_SIZE_METERS) -> Tuple[int, int]:
"""
Convert latitude and longitude coordinates into 2D integer grid cell coordinates.
1 degree of latitude is ~111,139 meters.
1 degree of longitude is ~111,139 * cos(lat) meters.
"""
meters_per_deg_lat = 111139.0
lat_step = grid_size_meters / meters_per_deg_lat
# Avoid division by zero near poles
cos_lat = max(0.0001, math.cos(math.radians(lat)))
lon_step = grid_size_meters / (meters_per_deg_lat * cos_lat)
grid_x = int(math.floor(lat / lat_step))
grid_y = int(math.floor(lon / lon_step))
return grid_x, grid_y
def get_cell_palette(
audio_files: Sequence[Path],
cell: Tuple[int, int],
palette_size: int = DEFAULT_PALETTE_SIZE,
) -> List[Path]:
"""
Deterministically select an active subset (palette) of audio files for a grid cell.
When moving within the same grid cell, the active palette remains identical.
When moving to a different grid cell, the palette changes.
"""
if not audio_files:
return []
if palette_size <= 0 or palette_size >= len(audio_files):
return list(audio_files)
cell_key = f"{cell[0]}:{cell[1]}".encode("utf-8")
seed = int(hashlib.sha256(cell_key).hexdigest()[:8], 16)
rng = random.Random(seed)
return rng.sample(list(audio_files), palette_size)
class BaseLocationProvider:
"""Base interface for location providers."""
def start(self) -> None:
raise NotImplementedError
def get_location(self) -> Optional[Dict[str, float]]:
raise NotImplementedError
def stop(self) -> None:
raise NotImplementedError
class PythonistaLocationProvider(BaseLocationProvider):
"""Wraps Pythonista's native location module."""
def __init__(self) -> None:
self._started = False
def start(self) -> None:
if not self._started and location is not None:
location.start_updates()
self._started = True
def get_location(self) -> Optional[Dict[str, float]]:
if location is not None:
return location.get_location()
return None
def stop(self) -> None:
if self._started and location is not None:
try:
location.stop_updates()
except Exception:
pass
self._started = False
class SimulatedLocationProvider(BaseLocationProvider):
"""
Simulates GPS coordinates with slight random walk drift.
Useful for off-device testing or simulator environments.
"""
def __init__(
self,
start_lat: float = 37.7749,
start_lon: float = -122.4194,
drift_meters_per_sec: float = 5.0,
) -> None:
self.lat = start_lat
self.lon = start_lon
self.drift_meters = drift_meters_per_sec
self.last_time = time.monotonic()
self._started = False
def start(self) -> None:
self.last_time = time.monotonic()
self._started = True
def get_location(self) -> Dict[str, float]:
now = time.monotonic()
dt = max(0.0, now - self.last_time)
self.last_time = now
# Drift coordinates by small random steps
deg_lat_step = (self.drift_meters * dt) / 111139.0
self.lat += random.uniform(-deg_lat_step, deg_lat_step)
self.lon += random.uniform(-deg_lat_step, deg_lat_step)
return {
"latitude": self.lat,
"longitude": self.lon,
"altitude": 10.0,
"horizontal_accuracy": 5.0,
"speed": 1.4,
"timestamp": time.time(),
}
def stop(self) -> None:
self._started = False
class BaseAudioPlayer:
"""Base interface for audio players."""
def play(self, file_path: Path, volume: float = 1.0) -> None:
raise NotImplementedError
def stop_all(self) -> None:
pass
class PythonistaAudioPlayer(BaseAudioPlayer):
"""Audio playback using Pythonista 3's built-in sound module."""
def __init__(self, max_concurrent: int = 8) -> None:
# Keep references to active sound.Player instances so Pythonista's GC doesn't cut sound short
self._players: collections.deque = collections.deque(maxlen=max_concurrent)
def play(self, file_path: Path, volume: float = 1.0) -> None:
if sound is None:
return
try:
player = sound.Player(str(file_path))
player.volume = max(0.0, min(1.0, volume))
player.play()
self._players.append(player)
except Exception as e:
print(f"[Player Error] Failed to play {file_path.name}: {e}", file=sys.stderr)
def stop_all(self) -> None:
while self._players:
player = self._players.popleft()
try:
player.stop()
except Exception:
pass
class DesktopAudioPlayer(BaseAudioPlayer):
"""
Fallback audio player for macOS (afplay) or console mocking.
"""
def __init__(self, max_concurrent: int = 8) -> None:
self.has_afplay = shutil.which("afplay") is not None
self._processes: collections.deque = collections.deque(maxlen=max_concurrent)
def play(self, file_path: Path, volume: float = 1.0) -> None:
if self.has_afplay:
try:
# Cleanup finished processes
self._processes = collections.deque(
[p for p in self._processes if p.poll() is None],
maxlen=self._processes.maxlen,
)
proc = subprocess.Popen(
["afplay", "-v", str(volume), str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self._processes.append(proc)
except Exception as e:
print(f"[Desktop Player Error] {e}", file=sys.stderr)
else:
# Fallback if no audio tool installed
print(f"[Mock Audio] Play: {file_path.name} (volume={volume})")
def stop_all(self) -> None:
while self._processes:
p = self._processes.popleft()
try:
p.terminate()
except Exception:
pass
class GPSMusicApp:
"""Coordinates GPS tracking and periodic audio playback."""
def __init__(
self,
audio_dir: str ¦ Path = DEFAULT_AUDIO_DIR,
interval: float = DEFAULT_INTERVAL,
grid_size_meters: float = DEFAULT_GRID_SIZE_METERS,
palette_size: int = DEFAULT_PALETTE_SIZE,
volume: float = DEFAULT_VOLUME,
location_provider: Optional[BaseLocationProvider] = None,
audio_player: Optional[BaseAudioPlayer] = None,
) -> None:
self.audio_dir = Path(audio_dir)
self.interval = float(interval)
self.grid_size_meters = float(grid_size_meters)
self.palette_size = int(palette_size)
self.volume = float(volume)
self.audio_files = find_audio_files(self.audio_dir)
if not self.audio_files:
raise RuntimeError(
f"No .aif / .aiff files found in {self.audio_dir.resolve()}."
)
if location_provider is not None:
self.location_provider = location_provider
elif HAS_PYTHONISTA_LOCATION:
self.location_provider = PythonistaLocationProvider()
else:
print("[Info] Pythonista 'location' module not detected; using simulated GPS coordinates.")
self.location_provider = SimulatedLocationProvider()
if audio_player is not None:
self.audio_player = audio_player
elif HAS_PYTHONISTA_SOUND:
self.audio_player = PythonistaAudioPlayer()
else:
self.audio_player = DesktopAudioPlayer()
self._current_cell: Optional[Tuple[int, int]] = None
self._current_palette: List[Path] = []
self._running = False
def update_palette(self, lat: float, lon: float) -> Tuple[Tuple[int, int], List[Path]]:
cell = coord_to_grid(lat, lon, self.grid_size_meters)
if cell != self._current_cell:
self._current_cell = cell
self._current_palette = get_cell_palette(self.audio_files, cell, self.palette_size)
print(
f"\n--- [Grid Transition] Cell {cell} ---"
f"\nActive pool ({len(self._current_palette)} sounds): "
f"{[p.name for p in self._current_palette]}"
)
return cell, self._current_palette
def tick(self) -> Optional[Path]:
"""Perform a single 2-second cycle: read GPS, pick audio, play audio."""
loc = self.location_provider.get_location()
if loc is None:
print("[Waiting for GPS fix... playing fallback random sound]")
candidate_pool = self.audio_files
current_cell_str = "Acquiring..."
lat_str, lon_str = "N/A", "N/A"
else:
lat = loc["latitude"]
lon = loc["longitude"]
cell, candidate_pool = self.update_palette(lat, lon)
current_cell_str = f"({cell[0]}, {cell[1]})"
accuracy = loc.get("horizontal_accuracy", 0.0)
lat_str = f"{lat:.6f}"
lon_str = f"{lon:.6f} (±{accuracy:.1f}m)"
if not candidate_pool:
return None
selected_file = random.choice(candidate_pool)
print(f"[GPS: {lat_str}, {lon_str}] Cell: {current_cell_str} -> Playing: {selected_file.name}")
self.audio_player.play(selected_file, volume=self.volume)
return selected_file
def run(self, max_ticks: Optional[int] = None) -> None:
"""Run the playback loop every `self.interval` seconds."""
print(f"Starting GPS Music Player...")
print(f"Directory: {self.audio_dir.resolve()} ({len(self.audio_files)} files)")
print(f"Interval: {self.interval}s ¦ Grid Cell: {self.grid_size_meters}m ¦ Palette Size: {self.palette_size}")
print("Press Ctrl+C or Stop to exit.\n")
self.location_provider.start()
self._running = True
tick_count = 0
try:
next_tick = time.monotonic()
while self._running:
self.tick()
tick_count += 1
if max_ticks is not None and tick_count >= max_ticks:
break
next_tick += self.interval
sleep_duration = next_tick - time.monotonic()
if sleep_duration > 0:
time.sleep(sleep_duration)
else:
# System fell behind, reset baseline
next_tick = time.monotonic()
except KeyboardInterrupt:
print("\nPlayback interrupted by user.")
finally:
self.stop()
def stop(self) -> None:
"""Cleanup location updates and audio players."""
self._running = False
print("Stopping location updates and cleaning up audio...")
try:
self.location_provider.stop()
except Exception as e:
print(f"Error stopping location service: {e}", file=sys.stderr)
try:
self.audio_player.stop_all()
except Exception as e:
print(f"Error stopping audio players: {e}", file=sys.stderr)
print("GPS Music Player exited cleanly.")
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Pythonista 3 GPS-based ambient audio player."
)
parser.add_argument(
"--dir",
"-d",
default=DEFAULT_AUDIO_DIR,
help=f"Directory containing .aif files (default: {DEFAULT_AUDIO_DIR})",
)
parser.add_argument(
"--interval",
"-i",
type=float,
default=DEFAULT_INTERVAL,
help=f"Interval in seconds between sound plays (default: {DEFAULT_INTERVAL})",
)
parser.add_argument(
"--grid-size",
"-g",
type=float,
default=DEFAULT_GRID_SIZE_METERS,
help=f"Grid size in meters per coordinate cell (default: {DEFAULT_GRID_SIZE_METERS})",
)
parser.add_argument(
"--palette-size",
"-p",
type=int,
default=DEFAULT_PALETTE_SIZE,
help=f"Active audio palette size per cell (default: {DEFAULT_PALETTE_SIZE})",
)
parser.add_argument(
"--volume",
"-v",
type=float,
default=DEFAULT_VOLUME,
help=f"Playback volume between 0.0 and 1.0 (default: {DEFAULT_VOLUME})",
)
parser.add_argument(
"--simulate-gps",
action="store_true",
help="Force simulated GPS movement even if native location is available",
)
parser.add_argument(
"--max-ticks",
type=int,
default=None,
help="Optional maximum number of iterations before automatic exit",
)
return parser.parse_args(argv)
def main() -> None:
args = parse_args()
loc_provider = SimulatedLocationProvider() if args.simulate_gps else None
app = GPSMusicApp(
audio_dir=args.dir,
interval=args.interval,
grid_size_meters=args.grid_size,
palette_size=args.palette_size,
volume=args.volume,
location_provider=loc_provider,
)
app.run(max_ticks=args.max_ticks)
if __name__ == "__main__":
main()